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
+
+
+
+ Nick |
+ Delete |
+
+
+
+ {% for credential in credentials %}
+
+ {{ credential.nick }} |
+ |
+
+ {% endfor %}
+
+
+
Tokens
+
+
+
+ OS |
+ Browser |
+ Ip Address |
+ Date Issued |
+ Date Expires |
+ Delete |
+
+
+
+ {% for token in tokens_pretty %}
+
+ {{ token.user_agent.platform }} |
+ {{ token.user_agent.browser }} {{token.user_agent.version }} |
+ {{ token.ip_address }} |
+ {{ token.date_issued }} |
+ {{ token.date_expired }} |
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+