unlinking keys

This commit is contained in:
Anedroid 2022-12-26 19:57:52 +01:00
parent e6524dce00
commit 88474a5ca6
Signed by: anedroid
GPG Key ID: F149EE15E69C7F45
6 changed files with 194 additions and 64 deletions

View File

@ -1,4 +1,7 @@
import sqlite3
import logging
DEBUG = False
def dict_factory(cursor, row):
fields = [column[0] for column in cursor.description]
@ -13,12 +16,15 @@ def generateToken(length):
class auth:
ENABLE_REGISTRATION = True
LINK_EXPIRE = 100*60
LINK_EXPIRE = 10*60
ANTIC_EXPIRE = 60*60*24
hash = None
username = None
# If outdated, userInfo will fetch data from database. Instead, it can be updated directly by this library to reduce disk IO.
user = {"outdated": True}
# User row cache. It's enough to ask database once and update cache only when asked for outdated or missing columns.
user = {}
userOutdated = []
anticsrf = False
def __init__(self, dbFile):
"""
@ -26,6 +32,8 @@ class auth:
"""
self.con = sqlite3.connect(dbFile)
self.con.row_factory = dict_factory
if (DEBUG):
self.con.set_trace_callback(logging.warning)
self.cur = self.con.cursor()
self.cur.execute("""
@ -52,7 +60,7 @@ class auth:
""")
self.garbageCollector()
def garbageCollector(self):
"""
delete all unlinked keys
@ -60,7 +68,7 @@ class auth:
self.cur.execute("DELETE FROM keys WHERE user IS NULL")
self.cur.execute("UPDATE users SET link_token = NULL, link_token_time = NULL WHERE link_token_time + ? - strftime('%s') <= 0", (self.LINK_EXPIRE, ))
self.con.commit()
def passKey(self, hash):
"""
pass given key to the object
@ -71,46 +79,97 @@ class auth:
if (not key):
self.registerKey()
return # if key is just registered, there is not username yet
self.username = key['username']
self.updateKey()
# we do not need to update cache right now
def fetchKey(self):
"""
get current key and username
"""
if (not self.hash):
return None
res = self.cur.execute("SELECT keys.*, users.name as username FROM users, keys WHERE users.id = keys.user AND keys.hash = ?", (self.hash, ))
return res.fetchone()
def getKeys(self):
"""
get all keys of current user
"""
if (not self.username):
return None
res = self.cur.execute("SELECT * FROM users, keys WHERE users.id = keys.user AND users.name = ?", (self.username, ))
return res.fetchall()
def userInfo(self, key=None):
def userInfo(self, key):
"""
get user row from database/cache
"""
if (not self.username):
return None
if (not self.user['outdated']):
return self.user if not key else self.user[key]
if (DEBUG):
logging.warning({"user": self.user, "userOutdated": self.userOutdated, "key": key})
if (key in self.user and key not in self.userOutdated):
return self.user[key]
res = self.cur.execute("SELECT * FROM users WHERE users.name = ?", (self.username, ))
self.user = res.fetchone()
self.user['outdated'] = False
return self.user if not key else self.user[key]
self.userOutdated.clear()
return self.user[key]
def updateUserInfo(self, key, value):
"""
handy function for quick update self.user and self.userOutdated
for outdating it's enough to self.userOutdated.append(key)
"""
self.user[key] = value
while (key in self.userOutdated): # remove duplicates
self.userOutdated.remove(key)
def genAnticSRF(self):
"""
generate antic cross-site request forgery token
There's one token per session.
"""
if (not self.username):
return None
# skip generating token if already generated for this session
if (self.anticsrf):
return self.userInfo('anticsrf')
token = generateToken(4)
self.cur.execute("UPDATE users SET anticsrf = ?, anticsrf_time = strftime('%s') WHERE name = ?", (token, self.username))
self.con.commit()
self.anticsrf = True
self.updateUserInfo('anticsrf', token)
self.userOutdated.append('anticsrf_time')
return token
def checkAnticSRF(self, token):
"""
check antic cross-site request forgery token validity
Remider: there's one token per session
TODO: anticsrf expiration
"""
if (not self.username):
return None
validity = token == self.userInfo('anticsrf')
self.cur.execute("UPDATE users SET anticsrf = NULL, anticsrf_time = NULL WHERE name = ?", (self.username, ))
self.con.commit()
self.updateUserInfo('anticsrf', None)
self.updateUserInfo('anticsrf_time', None)
return validity
def registerKey(self):
"""
insert new key into database (will be deleted if not linked)
@ -118,44 +177,41 @@ class auth:
"""
if (not self.hash):
return None
self.cur.execute("INSERT INTO keys (hash) VALUES (?)", (self.hash, ))
# unlinked key is deleted anyway, so why don't wait with commit until linking and avoid unneccessary disk IO?
def updateKey(self):
"""
touch current key timestamp
"""
if (not self.hash):
return None
self.cur.execute("UPDATE keys SET last_seen = strftime('%s') WHERE hash = ?", (self.hash, ))
self.con.commit()
SUCCESS = 0
NAME_IN_USE = 1
ACTION_DISABLED = 2
BAD_TOKEN = 1
BAD_TOKEN = 3
KEY_IN_USE = 4
NOT_FOUND = 5
def registerUser(self, username):
"""
link new user to the current key
response codes:
0 - registered successfully
1 - name in use
2 - registration disabled
3 - (future) captcha challenge
"""
if (not self.hash):
return None
if (not self.ENABLE_REGISTRATION):
return self.ACTION_DISABLED
res = self.cur.execute("SELECT * FROM users WHERE name = ?", (username, ))
if (res.fetchone()):
return self.NAME_IN_USE
self.cur.execute("INSERT INTO users (name) VALUES (?)", (username, ))
uid = self.cur.lastrowid
self.cur.execute("UPDATE keys SET user = ? WHERE hash = ?", (uid, self.hash))
@ -164,22 +220,19 @@ class auth:
self.username = username
return self.SUCCESS
def requestLink(self, cancel=False):
"""
generate link token
"""
if (not self.username):
return None
if (cancel):
# self.cur.execute("UPDATE users SET link_token = NULL, link_token_time = NULL WHERE name = ?", (self.username, ))
self.burnLink(self.username)
self.con.commit()
if (self.user):
self.user['link_token'] = self.user['link_token_time'] = None
return True
trials = 3
token = None
@ -187,14 +240,15 @@ class auth:
token = generateToken(16)
res = self.cur.execute("SELECT * FROM users WHERE link_token = ?", (token, ))
if (res.fetchone()):
tokenSet = None
token = None
trials -= 1
if (trials < 0):
return False
self.cur.execute("UPDATE users SET link_token = ?, link_token_time = strftime('%s') WHERE name = ?", (token, self.username))
self.con.commit()
self.user['outdated'] = True
self.updateUserInfo('link_token', token)
self.userOutdated.append('link_token_time')
return token
@ -203,6 +257,8 @@ class auth:
force link token expiration
"""
self.cur.execute("UPDATE users SET link_token = NULL, link_token_time = NULL WHERE name = ?", (username, ))
self.updateUserInfo('link_token', None)
self.updateUserInfo('link_token_time', None)
def link(self, token):
"""
@ -210,7 +266,7 @@ class auth:
"""
if (not self.hash):
return None
res = self.cur.execute("SELECT id, name FROM users WHERE link_token = ?", (token, ))
res = res.fetchone()
if (res):
@ -218,9 +274,28 @@ class auth:
self.burnLink(res['name'])
self.con.commit()
self.username = res['name']
return auth.SUCCESS
return self.SUCCESS
else:
return auth.BAD_TOKEN
return self.BAD_TOKEN
def unlink(self, hash):
"""
unlink given key from current user (since there is no such thing, the key is just deleted)
"""
if (not self.username or not self.hash):
return None
if (hash == self.hash):
return self.KEY_IN_USE
myKeys = self.getKeys()
for key in myKeys:
if (key['hash'] == hash):
self.cur.execute("DELETE FROM keys WHERE hash = ?", (hash, ))
self.con.commit()
return self.SUCCESS
return self.NOT_FOUND
if (__name__ == '__main__'):

View File

@ -34,27 +34,24 @@ else:
current = hash == auth.hash
name = key['name']
if (name):
name = '"' + name + '"'
else:
name = '[no name]'
txt = '* ' + name
name = '"' + name + '"' if name else '[no name]'
label = '* ' + name
if (current):
txt += ' (currently used)'
print(txt)
label += ' (currently used)'
print(label)
print('hash:', hash)
print('last seen:', lastSeen)
if (not current):
print('=> unlink.gmi?{} unlink'.format(auth.genAnticSRF() + hash))
linkToken = auth.userInfo('link_token')
if(linkToken):
from datetime import datetime
linkTokenExpire = datetime.fromtimestamp(auth.userInfo('link_token_time') + auth.LINK_EXPIRE)
delta = linkTokenExpire - datetime.now()
seconds = delta.seconds
minutes = seconds // 60
seconds = seconds % 60
expire = datetime.fromtimestamp(auth.userInfo('link_token_time') + auth.LINK_EXPIRE)
delta = expire - datetime.now()
minutes = delta.seconds // 60
seconds = delta.seconds % 60
# TODO: calibration - it displays 00:00 for one second
def zero(n):
return str(n) if n>10 else '0'+str(n)
@ -62,4 +59,4 @@ else:
print('Token {} will expire in {}:{}'.format(linkToken, zero(minutes), zero(seconds)))
print('=> link.gmi?cancel cancel')
else:
print('=> link.gmi Link new key')
print('=> link.gmi link new key')

View File

@ -22,9 +22,13 @@ if (auth.username):
if (not query):
# empty
from datetime import datetime
print('20 text/gemini\r\n')
token = auth.requestLink()
if (not token):
print('40 Unknown error\r\n')
exit()
print('20 text/gemini\r\n')
print('Here\'s your token:', token)
print('Open this page on new device and enter this code.')
print('This token will expire in', int(auth.LINK_EXPIRE/60), 'minutes')
@ -37,7 +41,7 @@ if (auth.username):
print('20 text/gemini\r\n')
print('Tip: open this link on new device in order to link new key to your account')
else:
print('50 What are you trying to do?\r\n')
print('59 What are you trying to do?\r\n')
else:
# mismatch
if (not query):
@ -52,6 +56,6 @@ else:
print('=> index.gmi back to home')
elif (res == auth.BAD_TOKEN):
print('20 text/gemini\r\n')
print('It seems you entered invalid or expired token. Try to generate a new one.')
print('It seems have you entered invalid or expired token. Try to generate a new one.')
else:
print('40 Unknown error\r\n')

View File

@ -25,7 +25,7 @@ else:
if (not auth.ENABLE_REGISTRATION):
print('50 Registration is disabled\r\n')
exit()
username = os.environ.get('QUERY_STRING')
if (not username):
# empty

54
public/cgi/account/unlink.gmi Executable file
View File

@ -0,0 +1,54 @@
#!/usr/bin/python3
import os
import sys
os.chdir('..')
sys.path.append('lib')
hash = os.environ.get('TLS_CLIENT_HASH')
if (not hash):
# no CC
print('60 Authentication is required\r\n')
exit()
from auth import auth
auth = auth('data/data.db')
auth.passKey(hash)
if (not auth.username):
# mismatch
print('61 Unknown key\r\n')
else:
# match
query = os.environ.get('QUERY_STRING')
if (not query):
# empty
print('30 index.gmi\r\n')
else:
anticsrf = query[:4]
hash = query[4:]
if (not anticsrf or not hash):
print('59 What are you trying to do?\r\n')
exit()
# anticsrf+hash
if (auth.checkAnticSRF(anticsrf)):
res = auth.unlink(hash)
if (res == auth.SUCCESS):
print('30 index.gmi\r\n')
elif (res == auth.KEY_IN_USE):
print('20 text/gemini\r\n')
print('You have requested to delete the key, which is being used by you RIGHT NOW.')
print('This could lead to the loss of your account access. If you want to proceed, authenticate with another key and try again.')
print('=> index.gmi back to home')
elif (res == auth.NOT_FOUND):
print('20 text/gemini\r\n')
print('Failed to delete non-existing key, or key which does not belong to you.')
print('Maybe you\'re trying to delete already deleted key?')
print('=> index.gmi back to home')
else:
print('40 Unknown error\r\n')
else:
print('50 Bad Antic SRF (security)\r\n')

View File

@ -20,7 +20,7 @@ auth.passKey(hash)
print('Your hash:', auth.hash)
if (auth.username):
print('Your username:', auth.username)
print('=> account/index.gmi Manage your account')
print('=> account/index.gmi manage your account')
else:
print('=> account/register.gmi register')
print('=> link.gmi link existing account')
print('=> account/link.gmi link existing account')