overhauled auth system

This commit is contained in:
iou1name 2019-06-22 18:37:16 -04:00
parent c744143431
commit 0f6fa87668
12 changed files with 298 additions and 172 deletions

124
auth.py
View File

@ -3,6 +3,9 @@
Contains authentication methods for the app. Contains authentication methods for the app.
""" """
import os import os
import re
import time
import sqlite3
import functools import functools
from fido2.client import ClientData from fido2.client import ClientData
@ -10,12 +13,13 @@ from fido2.server import Fido2Server, RelyingParty
from fido2.ctap2 import AttestationObject, AuthenticatorData, \ from fido2.ctap2 import AttestationObject, AuthenticatorData, \
AttestedCredentialData AttestedCredentialData
from fido2 import cbor from fido2 import cbor
from flask import Blueprint, session, render_template, request, abort, \ from flask import Blueprint, session, render_template, request, \
redirect, url_for, g redirect, url_for, jsonify
from passlib.hash import argon2 from passlib.hash import argon2
import config
import db import db
import config
from tools import make_error
auth_views = Blueprint("auth_views", __name__) auth_views = Blueprint("auth_views", __name__)
@ -37,12 +41,21 @@ def auth_required(func):
data = db.get_user(username) data = db.get_user(username)
if not data: if not data:
session.pop('username') session.pop('username')
session.pop('token')
return redirect(url_for('auth_views.login')) return redirect(url_for('auth_views.login'))
token_hash = data[2] user_id = data[0]
if not token_hash:
tokens = db.get_tokens(user_id)
if not tokens:
return redirect(url_for('auth_views.login')) return redirect(url_for('auth_views.login'))
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): if argon2.verify(token, token_hash):
db.refresh_token(token_line[0])
return func(*args, **kwargs) return func(*args, **kwargs)
else: else:
session.pop('token') session.pop('token')
@ -86,10 +99,10 @@ def register_complete():
username = session.get('username') username = session.get('username')
if not username: if not username:
return 'invalid' return 'invalid'
session.pop('username')
data = db.get_user(username) data = db.get_user(username)
if not data: if not data:
session.pop('username')
return 'invalid' return 'invalid'
user_id = data[0] user_id = data[0]
@ -97,30 +110,34 @@ def register_complete():
client_data = ClientData(data['clientDataJSON']) client_data = ClientData(data['clientDataJSON'])
att_obj = AttestationObject(data['attestationObject']) 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( auth_data = server.register_complete(
session.pop('state'), session.pop('state'),
client_data, client_data,
att_obj att_obj
) )
db.set_credential(user_id, auth_data.credential_data) db.set_credential(user_id, nick, auth_data.credential_data)
return cbor.encode({'status': 'OK'}) return jsonify(ok=True)
@auth_views.route('/api/authenticate/begin', methods=['POST']) @auth_views.route('/api/authenticate/begin', methods=['POST'])
def authenticate_begin(): def authenticate_begin():
username = session.get('username') data = cbor.decode(request.get_data())
if not username: username = data.get('username')
return 'invalid' user = db.get_user(username)
if not user:
data = db.get_user(session['username']) return make_error(404, "username not found")
if not data: session['username'] = username
session.pop('username') user_id = user[0]
return 'invalid'
user_id = data[0]
credentials = db.get_credentials(user_id) 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) auth_data, state = server.authenticate_begin(credentials)
session['state'] = state session['state'] = state
@ -130,17 +147,14 @@ def authenticate_begin():
@auth_views.route('/api/authenticate/complete', methods=['POST']) @auth_views.route('/api/authenticate/complete', methods=['POST'])
def authenticate_complete(): def authenticate_complete():
username = session.get('username') username = session.get('username')
if not username: user = db.get_user(username)
return 'invalid' if not user:
data = db.get_user(session['username'])
if not data:
session.pop('username') session.pop('username')
return 'invalid' return make_error(404, "username not found")
user_id = data[0] user_id = user[0]
credentials = db.get_credentials(user_id) 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()) data = cbor.decode(request.get_data())
credential_id = data['credentialId'] credential_id = data['credentialId']
@ -159,10 +173,10 @@ def authenticate_complete():
token = os.urandom(32) token = os.urandom(32)
token_hash = argon2.hash(token) token_hash = argon2.hash(token)
db.set_user_token(username, token_hash) db.set_token(user_id, token_hash)
session['token'] = token session['token'] = token
return cbor.encode({'status': 'OK'}) return jsonify(ok=True)
@auth_views.route('/register', methods=['GET', 'POST']) @auth_views.route('/register', methods=['GET', 'POST'])
@ -175,52 +189,44 @@ def register():
if request.method == 'GET': if request.method == 'GET':
params = { params = {
'title': 'Register',
'heading': 'Register an account',
'form_url': url_for('auth_views.register'), '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') username = request.form.get('username')
email = request.form.get('email')
if len(username) > 64: try:
return "username too long" assert 64 >= len(username) >= 3
elif len(username) < 3: except AssertionError:
return "username too short" return make_error(400, "username too long/short")
db.set_user(username) # TODO: handle username error 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['username'] = username
session['user_id'] = user_id
params = { params = {
'title': 'Register', 'url_prefix': config.url_prefix,
'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) return render_template('register_key.html', **params)
@auth_views.route('/login', methods=['GET', 'POST']) @auth_views.route('/login')
def login(): def login():
""" """
Login page. Login page.
""" """
if request.method == 'GET':
params = { params = {
'title': 'Login', 'url_prefix': config.url_prefix,
'heading': 'Login',
'form_url': url_for('auth_views.login'),
} }
return render_template('auth.html', **params) return render_template('login.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)

95
db.py
View File

@ -2,6 +2,7 @@
""" """
A module for interacting with Juice's database. A module for interacting with Juice's database.
""" """
import time
import sqlite3 import sqlite3
import threading import threading
@ -19,19 +20,29 @@ def init_db():
try: try:
cur.execute("SELECT * FROM user LIMIT 1").fetchone() cur.execute("SELECT * FROM user LIMIT 1").fetchone()
cur.execute("SELECT * FROM credential LIMIT 1").fetchone() cur.execute("SELECT * FROM credential LIMIT 1").fetchone()
cur.execute("SELECT * FROM token LIMIT 1").fetchone()
except sqlite3.OperationalError: except sqlite3.OperationalError:
cur.execute("CREATE TABLE user(" cur.execute("CREATE TABLE user("
"id INTEGER PRIMARY KEY AUTOINCREMENT, " "id INTEGER PRIMARY KEY, "
"name TEXT UNIQUE, " "username TEXT UNIQUE, "
"token_hash TEXT" "email TEXT UNIQUE"
")" ")"
) )
cur.execute("CREATE TABLE credential(" cur.execute("CREATE TABLE credential("
"id INTEGER PRIMARY KEY AUTOINCREMENT, " "id INTEGER PRIMARY KEY, "
"user_id INTEGER REFERENCES user(id) ON UPDATE CASCADE, " "user_id INTEGER REFERENCES user(id) ON UPDATE CASCADE, "
"nick TEXT, "
"credential BLOB" "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.commit()
con.close() con.close()
init_db() init_db()
@ -50,26 +61,15 @@ def db_execute(*args, **kwargs):
return res return res
def set_user(username): def set_user(username, email):
""" """
Adds a new user. Adds a new user.
""" """
db_execute( user_id = db_execute(
"INSERT INTO user(name) VALUES (?)", "INSERT INTO user(username, email) VALUES (?, ?)",
(username,) (username, email)
) ).lastrowid
return True return user_id
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): def get_user(username):
@ -77,21 +77,60 @@ def get_user(username):
Returns a user entry. Returns a user entry.
""" """
data = db_execute( data = db_execute(
"SELECT * FROM user WHERE name = ?", "SELECT * FROM user WHERE username = ?",
(username,) (username,)
).fetchone() ).fetchone()
return data 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. Adds a credential to the database.
""" """
db_execute( cred_id = db_execute(
"INSERT INTO credential(user_id, credential) VALUES (?, ?)", "INSERT INTO credential(user_id, nick, credential) "
(user_id, credential) "VALUES (?, ?, ?)",
) (user_id, nick, credential)
return True ).lastrowid
return cred_id
def get_credentials(user_id): def get_credentials(user_id):

View File

@ -11,9 +11,10 @@ import requests
from flask import Flask, render_template, request, abort, jsonify from flask import Flask, render_template, request, abort, jsonify
from flask import Blueprint from flask import Blueprint
import config
import auth import auth
import config
from auth import auth_required from auth import auth_required
from tools import make_error
class RelayDevice: class RelayDevice:
""" """
@ -225,6 +226,7 @@ def edit():
@app_views.route('/new_device') @app_views.route('/new_device')
@auth_required
def new_device(): def new_device():
""" """
Allows adding a new device. Accepts device_type parameter, returns Allows adding a new device. Accepts device_type parameter, returns
@ -255,6 +257,7 @@ def new_device():
@app_views.route('/lock_device') @app_views.route('/lock_device')
@auth_required
def lock_device(): def lock_device():
""" """
Locks or unlocks a device to prevent or allow editing it's fields. Locks or unlocks a device to prevent or allow editing it's fields.
@ -278,6 +281,7 @@ def lock_device():
@app_views.route('/delete') @app_views.route('/delete')
@auth_required
def delete(): def delete():
""" """
Deletes a device. Deletes a device.
@ -296,18 +300,10 @@ def delete():
return jsonify(True) 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 = Flask(__name__)
app.register_blueprint(app_views, url_prefix=config.url_prefix) app.register_blueprint(app_views, url_prefix=config.url_prefix)
app.register_blueprint(auth.auth_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'): if os.path.isfile('secret_key'):
with open('secret_key', 'rb') as file: with open('secret_key', 'rb') as file:
app.secret_key = file.read() app.secret_key = file.read()
@ -320,4 +316,4 @@ network = init_network()
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5300) app.run(host='0.0.0.0', port=5300, debug=True)

60
static/juice-auth.js Normal file
View File

@ -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 + '/';
});
}

View File

@ -24,6 +24,11 @@ nav span {
margin-right: 0.5em; margin-right: 0.5em;
} }
nav a {
text-decoration: none;
color: inherit;
}
nav span:hover { nav span:hover {
background-color: lightgray; background-color: lightgray;
} }

View File

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Juice - {{ title }}</title>
<link rel="stylesheet" type="text/css" href="/static/juice.css">
<script type="text/javascript" src="/static/cbor.js"></script>
</head>
<body>
<h1>{{ heading }}</h1>
<form method="post" action="{{ form_url }}">
Username: <input name="username" maxlength="64" required><br>
<input type="submit" value="Submit">
</form>
</body>
</html>

View File

@ -1,53 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Juice - {{ title }}</title>
<link rel="stylesheet" type="text/css" href="/static/juice.css">
<script type="text/javascript" src="/static/cbor.js"></script>
</head>
<body>
<h1>{{ heading }}</h1>
<p>Touch your authenticactor device now...
<script>
fetch('{{ api_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.{% if title == 'Register' %}create{% else %}get{% endif %}(options);
{% if title == 'Register' %}
}).then(function(attestation) {
return fetch('{{ api_complete }}', {
method: 'POST',
headers: {'Content-Type': 'application/cbor'},
body: CBOR.encode({
"attestationObject": new Uint8Array(attestation.response.attestationObject),
"clientDataJSON": new Uint8Array(attestation.response.clientDataJSON),
})
});
{% else %}
}).then(function(assertion) {
return fetch('{{ api_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)
})
});
{% endif %}
}).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_for("app_views.index") }}';
});
</script>
</body>
</html>

View File

@ -9,9 +9,10 @@
</head> </head>
<body> <body>
<header> <header>
<h1>Juice</h1>
<nav> <nav>
<span onclick="new_device()" title="Add new device"><span class="font-awesome">&#xe802;</span></span> <span title="Add new device" onclick="new_device()"><span class="font-awesome">&#xe802;</span></span>
<span title="Register a new authenticator">Register</span> <span title="Manage authenticators"><a href="./manage">Manage</a></span>
</nav> </nav>
</header> </header>
<main> <main>

21
templates/login.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>Juice - Login</title>
<link rel="stylesheet" type="text/css" href="/static/juice.css">
<script type="text/javascript" src="/static/cbor.js"></script>
<script type="text/javascript" src="/static/juice-auth.js"></script>
<script>url_prefix = '{{ url_prefix }}';</script>
</head>
<body>
<main>
<div id="devices">
<div class="device">
<h1>Login</h1>
<p><label for="username">Username</label> <input id="username" type="text" minlength="3" maxlength="64" required>
<p><input type="button" value="Login" onclick="login()">
</div>
</div>
</main>
</body>
</html>

31
templates/register.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>Juice - Register</title>
<link rel="stylesheet" type="text/css" href="/static/juice.css">
</head>
<body>
<main>
<div id="devices">
<div class="device">
<h1>Register</h1>
<form method="post" action="{{ form_url }}">
<table>
<tbody>
<tr>
<td>Username</td><td><input name="username" type="text" minlength="3" maxlength="64" required></td>
</tr>
<tr>
<td>Email</td><td><input name="email" type="email"></td>
</tr>
<tr>
<td><input type="submit" value="Submit"></td>
</tr>
</tbody>
</table>
</form>
</div>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title>Juice - Register Security Key</title>
<link rel="stylesheet" type="text/css" href="/static/juice.css">
<script type="text/javascript" src="/static/cbor.js"></script>
<script type="text/javascript" src="/static/juice-auth.js"></script>
<script>url_prefix = '{{ url_prefix }}';</script>
</head>
<body>
<main>
<div id="devices">
<div class="device">
<h1>Register Security Key</h1>
<p>Upon clicking submit, your security key will begin flashing. Have it ready.
<p><label for="security_key_nick">Security key nick</label> <input id="security_key_nick" type="text" minlength="1" maxlength="64" required>
<p><input type="button" value="Submit" onclick="register()">
</div>
</div>
</main>
</body>
</html>

14
tools.py Normal file
View File

@ -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