diff --git a/auth.py b/auth.py index a0e7456..6da9c71 100644 --- a/auth.py +++ b/auth.py @@ -7,6 +7,7 @@ import re import time import sqlite3 import functools +from datetime import datetime from fido2.client import ClientData from fido2.server import Fido2Server, RelyingParty @@ -16,6 +17,7 @@ from fido2 import cbor from flask import Blueprint, session, render_template, request, \ redirect, url_for, jsonify from passlib.hash import argon2 +from werkzeug.useragents import UserAgent import db import config @@ -38,24 +40,24 @@ def auth_required(func): if not username or not token: return redirect(url_for('auth_views.login')) - data = db.get_user(username) - if not data: + user = db.get_user(username) + if not user: session.pop('username') session.pop('token') return redirect(url_for('auth_views.login')) - user_id = data[0] + user_id = user['id'] tokens = db.get_tokens(user_id) if not tokens: return redirect(url_for('auth_views.login')) for token_line in tokens: - token_hash = token_line[2] - date_expired = token_line[4] + token_hash = token_line['token_hash'] + date_expired = token_line['date_expired'] if int(time.time()) >= date_expired: continue if argon2.verify(token, token_hash): - db.refresh_token(token_line[0]) + db.refresh_token(token_line['id']) return func(*args, **kwargs) else: session.pop('token') @@ -72,14 +74,14 @@ def register_begin(): if not username: return 'invalid' - data = db.get_user(username) - if not data: + user = db.get_user(username) + if not user: session.pop('username') return 'invalid' - user_id = data[0] + user_id = user['id'] exist_cred = db.get_credentials(user_id) - exist_cred = [AttestedCredentialData(cd[2]) for cd in exist_cred] + exist_cred = [AttestedCredentialData(c['credential']) for c in exist_cred] registration_data, state = server.register_begin({ 'id': str(user_id).encode('utf8'), @@ -101,10 +103,10 @@ def register_complete(): return 'invalid' session.pop('username') - data = db.get_user(username) - if not data: + user = db.get_user(username) + if not user: return 'invalid' - user_id = data[0] + user_id = user['id'] data = cbor.decode(request.get_data()) client_data = ClientData(data['clientDataJSON']) @@ -134,10 +136,10 @@ def authenticate_begin(): if not user: return make_error(404, "username not found") session['username'] = username - user_id = user[0] + user_id = user['id'] credentials = db.get_credentials(user_id) - credentials = [AttestedCredentialData(cd[3]) for cd in credentials] + credentials =[AttestedCredentialData(c['credential']) for c in credentials] auth_data, state = server.authenticate_begin(credentials) session['state'] = state @@ -151,10 +153,10 @@ def authenticate_complete(): if not user: session.pop('username') return make_error(404, "username not found") - user_id = user[0] + user_id = user['id'] credentials = db.get_credentials(user_id) - credentials = [AttestedCredentialData(cd[3]) for cd in credentials] + credentials =[AttestedCredentialData(c['credential']) for c in credentials] data = cbor.decode(request.get_data()) credential_id = data['credentialId'] @@ -173,7 +175,9 @@ def authenticate_complete(): token = os.urandom(32) token_hash = argon2.hash(token) - db.set_token(user_id, token_hash) + user_agent = request.user_agent.string + ip_address = request.headers.get("X-Real-Ip") + db.set_token(user_id, user_agent, ip_address, token_hash) session['token'] = token return jsonify(ok=True) @@ -230,3 +234,72 @@ def login(): 'url_prefix': config.url_prefix, } return render_template('login.html', **params) + + +@auth_views.route('/manage') +@auth_required +def manage(): + """ + Allows a user to manage their security keys and tokens. + """ + url_prefix = config.url_prefix + username = session['username'] + user_id = db.get_user(username)['id'] + + credentials = db.get_credentials(user_id) + + tokens = db.get_tokens(user_id) + tokens_pretty = [] + for token in tokens: + token_pretty = {} + token_pretty['id'] = token['id'] + token_pretty['user_agent'] = UserAgent(token['user_agent']) + token_pretty['ip_address'] = token['ip_address'] + di = token['date_issued'] + di = datetime.utcfromtimestamp(di).strftime('%Y-%m-%d') + token_pretty['date_issued'] = di + de = token['date_expired'] + de = datetime.utcfromtimestamp(de).strftime('%Y-%m-%d') + token_pretty['date_expired'] = de + tokens_pretty.append(token_pretty) + return render_template('manage.html', **locals()) + + +@auth_views.route('/delete_key') +@auth_required +def delete_key(): + """ + Allows a user to delete a security key credential. + """ + cred_id = request.args.get('key_id') + username = session['username'] + user_id = db.get_user(username)['id'] + + cred = db.get_credential(cred_id) + if not cred: + return make_error(404, "security key not found") + if cred['user_id'] == user_id: + db.delete_credential(cred_id) + return jsonify(ok=True) + else: + return make_error(404, "security key not found") + + +@auth_views.route('/delete_token') +@auth_required +def delete_token(): + """ + Allows a user to delete a token. + """ + token_id = request.args.get('token_id') + username = session['username'] + user_id = db.get_user(username)['id'] + + token = db.get_token(token_id) + if not token: + return make_error(404, "token not found") + if token['user_id'] == user_id: + db.delete_token(token_id) + return jsonify(ok=True) + else: + return make_error(404, "token not found") diff --git a/db.py b/db.py index e9cc471..fbbb513 100644 --- a/db.py +++ b/db.py @@ -38,6 +38,8 @@ def init_db(): cur.execute("CREATE TABLE token(" "id INTEGER PRIMARY KEY, " "user_id INTEGER REFERENCES user(id) ON UPDATE CASCADE, " + "user_agent TEXT, " + "ip_address TEXT, " "token_hash TEXT, " "date_issued INTEGER, " "date_expired INTEGER" @@ -55,6 +57,7 @@ def db_execute(*args, **kwargs): """ with sqlite3.connect('juice.db') as con: DB_LOCK.acquire() + con.row_factory = sqlite3.Row cur = con.cursor() res = cur.execute(*args, **kwargs) DB_LOCK.release() @@ -83,7 +86,7 @@ def get_user(username): return data -def set_token(user_id, token_hash): +def set_token(user_id, user_agent, ip_address, token_hash): """ Sets a user's token hash. """ @@ -91,13 +94,37 @@ def set_token(user_id, token_hash): 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) + "token(" + "user_id, user_agent, ip_address, token_hash, date_issued,date_expired" + ") " + "VALUES (?,?,?,?,?,?)", + (user_id, user_agent, ip_address, token_hash, date_issued,date_expired) ).lastrowid return token_id +def get_token(token_id): + """ + Returns the token of the specified id. + """ + data = db_execute( + "SELECT * FROM token WHERE id = ?", + (token_id,) + ).fetchone() + return data + + +def delete_token(token_id): + """ + Deletes the token of the specified id. + """ + db_execute( + "DELETE FROM token WHERE id = ?", + (token_id,) + ).fetchone() + return True + + def get_tokens(user_id): """ Returns all tokens assigned to a user. @@ -133,6 +160,28 @@ def set_credential(user_id, nick, credential): return cred_id +def get_credential(cred_id): + """ + Returns the credential of the specified id. + """ + data = db_execute( + "SELECT * FROM credential WHERE id = ?", + (cred_id,) + ).fetchone() + return data + + +def delete_credential(cred_id): + """ + Deletes the credential of the specified id. + """ + db_execute( + "DELETE FROM credential WHERE id = ?", + (cred_id,) + ).fetchone() + return True + + def get_credentials(user_id): """ Returns all credentials registered to a user. diff --git a/static/juice.css b/static/juice.css index 63623d9..3192546 100644 --- a/static/juice.css +++ b/static/juice.css @@ -92,3 +92,23 @@ nav span:hover { color: red; cursor: pointer; } + +.manage-table { + border-collapse: collapse; +} + +.manage-table th { + padding-left: 0.5em; + padding-right: 0.5em; +} + +.manage-table td { + border: 1px solid darkgray; + padding-left: 0.5em; + padding-right: 0.5em; + text-align: center; +} + +.sub-table { + margin-top: 5em; +} diff --git a/static/juice.js b/static/juice.js index 2a90d6d..a6fb643 100644 --- a/static/juice.js +++ b/static/juice.js @@ -210,3 +210,41 @@ function delete_device(device) { device.remove() }); } + +function delete_key(key_id) { + if (!window.confirm("Are you sure you want to delete this key? This action may leave you unable to access your account.")) { return; } + let params = { + key_id: key_id, + }; + let query = Object.keys(params) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .join('&'); + + fetch(url_prefix + '/delete_key?' + query) + .then(function(response) { + return response.json(); + }) + .then(function(json) { + if (!json.ok) { return; } + document.querySelector('#key_' + key_id).remove(); + }); +} + +function delete_token(token_id) { + if (!window.confirm("Are you sure you want to delete this token?")) { return; } + let params = { + token_id: token_id, + }; + let query = Object.keys(params) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .join('&'); + + fetch(url_prefix + '/delete_token?' + query) + .then(function(response) { + return response.json(); + }) + .then(function(json) { + if (!json.ok) { return; } + document.querySelector('#token_' + token_id).remove(); + }); +} diff --git a/templates/index.html b/templates/index.html index e376fd1..44d5b81 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12,7 +12,7 @@

Juice

diff --git a/templates/manage.html b/templates/manage.html new file mode 100644 index 0000000..3958f9e --- /dev/null +++ b/templates/manage.html @@ -0,0 +1,59 @@ + + + + Juice - Manage + + + + + +
+
+
+

Security Keys

+ + + + + + + + + {% for credential in credentials %} + + + + + {% endfor %} + +
NickDelete
{{ credential.nick }}
+

Tokens

+ + + + + + + + + + + + + {% for token in tokens_pretty %} + + + + + + + + + {% endfor %} + +
OSBrowserIp AddressDate IssuedDate ExpiresDelete
{{ token.user_agent.platform }}{{ token.user_agent.browser }} {{token.user_agent.version }}{{ token.ip_address }}{{ token.date_issued }}{{ token.date_expired }}
+
+
+
+ +