diff --git a/config.ini b/config.ini index 053482d..c8d5d67 100644 --- a/config.ini +++ b/config.ini @@ -1,5 +1,5 @@ -# example stargazer config, nothing special here -listen = 127.0.0.1 +# listening on 0.0.0.0 binds to all interfaces, while 127.0.0.1 only to localhost +listen = 0.0.0.0 [:tls] store = certs @@ -10,3 +10,11 @@ root = public [localhost:/cgi] root = public cgi = on + +# repeated configuration for your domain +[example.com] +root = public + +[example.com:/cgi] +root = public +cgi = on \ No newline at end of file diff --git a/doc/authentication b/doc/authentication index cac9297..fd94b16 100644 --- a/doc/authentication +++ b/doc/authentication @@ -4,6 +4,8 @@ After linked, key cannot be unlinked. It can only be deleted. You need to have at least one key linked. When you link new key to the existing account, a random token is generated for authentication for short period of time. That token is burned after use. You can request your account to be deleted. To proceed, you have to solve simple anti-csrf challenge. +When the key is linked with account, a display name is being automatically set from certificate's issuer field. +You can change display name of your key. Because of nature of Gemini query strings, it's a two-step process: first, the key hash is temporary stored in database, actual renaming happens in the second step. Access points: /cgi - cgi scripts root @@ -35,6 +37,18 @@ Access points: match empty - redirect to index.gmi anticsrf+hash - verify & redirect to index.gmi | warning + /rename-request.gmi + no CC - request CC + mismatch - invalid CC + match + empty - which key would you like to rename? + anticsrf+hash - verify & redirect to rename.gmi | warning + /rename.gmi + no CC - request CC + mismatch - invalid CC + match + empty - choose your name for "[old name]" | redirect to rename-request.gmi + string - rename & redirect to index.gmi /delete.gmi - delete your account no CC - request CC mismatch - invalid CC @@ -53,7 +67,8 @@ CREATE TABLE IF NOT EXISTS users ( request_delete VARCHAR(16), request_delete_time INTEGER, anticsrf VARCHAR(4), - anticsrf_time INTEGER + anticsrf_time INTEGER, + request_rename VARCHAR(255) ) CREATE TABLE IF NOT EXISTS keys ( hash VARCHAR(255) PRIMARY KEY, diff --git a/lib/auth/__init__.py b/lib/auth/__init__.py index 107628f..7dec30d 100644 --- a/lib/auth/__init__.py +++ b/lib/auth/__init__.py @@ -20,11 +20,18 @@ class auth: ANTIC_EXPIRE = 60*60*24 hash = None + certName = None username = None + anticsrf = False + # 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 + + # User keys cache indexed by hashes + keys = {} + keysOutdated = ["all"] + # getKeys always returns all keys owned by user. def __init__(self, dbFile): """ @@ -45,7 +52,8 @@ class auth: request_delete VARCHAR(16), request_delete_time INTEGER, anticsrf VARCHAR(4), - anticsrf_time INTEGER + anticsrf_time INTEGER, + request_rename VARCHAR(255) ) """) self.cur.execute(""" @@ -59,21 +67,27 @@ class auth: ) """) + # TODO: database migration + # self.migrateDatabase() self.garbageCollector() def garbageCollector(self): """ - delete all unlinked keys + delete all unlinked keys and expired tokens """ + # garbageCollector is intended to run before caching initialization 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, )) + # field request_rename expire together with anticsrf + self.cur.execute("UPDATE users SET anticsrf = NULL, anticsrf_time = NULL, request_rename = NULL WHERE anticsrf_time + ? - strftime('%s') <= 0", (self.ANTIC_EXPIRE, )) self.con.commit() - def passKey(self, hash): + def passKey(self, hash, name=None): """ pass given key to the object """ self.hash = hash + self.certName = name key = self.fetchKey() if (not key): @@ -92,19 +106,48 @@ class auth: 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() + res = res.fetchone() - def getKeys(self): + if (res): + self.keys[self.hash] = res.copy() + del self.keys[self.hash]['username'] + del self.keys[self.hash]['hash'] + + return res + + def getKeys(self, require=[]): """ get all keys of current user + "require" argument controls which fields must be up to date - if ommited, there are no requirements! """ 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() + outdated = False + if ('all' not in self.keysOutdated): + for key in require: + if (key in self.keysOutdated): + outdated = True + break + else: + self.keysOutdated.remove('all') + outdated = True - def userInfo(self, key): + if (outdated): + res = self.cur.execute("SELECT keys.* FROM users, keys WHERE users.id = keys.user AND users.name = ?", (self.username, )) + res = res.fetchall() + + self.keys = {} + for key in res: + self.keys[key['hash']] = key + del key['hash'] + self.keysOutdated.clear() + + if (DEBUG): + logging.warning({"keys": self.keys, "keysOutdated": self.keysOutdated}) + return self.keys + + def userInfo(self, column): """ get user row from database/cache """ @@ -112,24 +155,36 @@ class auth: return None if (DEBUG): - logging.warning({"user": self.user, "userOutdated": self.userOutdated, "key": key}) + logging.warning({"user": self.user, "userOutdated": self.userOutdated, "requested": column}) - if (key in self.user and key not in self.userOutdated): - return self.user[key] + if (column in self.user and column not in self.userOutdated): + return self.user[column] res = self.cur.execute("SELECT * FROM users WHERE users.name = ?", (self.username, )) self.user = res.fetchone() self.userOutdated.clear() - return self.user[key] + return self.user[column] - def updateUserInfo(self, key, value): + def updateUserInfo(self, column, 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) + self.user[column] = value + while (column in self.userOutdated): # remove duplicates + self.userOutdated.remove(column) + + def updateKeyInfo(self, hash, column, value): + """ + handy function (at the moment almost inevitable) for quick update self.keys and self.keysOutdated + for outdating it's enough to self.keysOutdated.append(key) + """ + if (hash not in self.keys): + self.keys[hash] = {} + + self.keys[hash][column] = value + while (column in self.keysOutdated): # remove duplicates + self.keysOutdated.remove(column) def genAnticSRF(self): """ @@ -156,7 +211,6 @@ class auth: """ check antic cross-site request forgery token validity Remider: there's one token per session - TODO: anticsrf expiration """ if (not self.username): return None @@ -173,13 +227,13 @@ class auth: def registerKey(self): """ insert new key into database (will be deleted if not linked) - TODO: autoset common name """ if (not self.hash): return None - self.cur.execute("INSERT INTO keys (hash) VALUES (?)", (self.hash, )) + self.cur.execute("INSERT INTO keys (hash, name) VALUES (?, ?)", (self.hash, self.certName)) # unlinked key is deleted anyway, so why don't wait with commit until linking and avoid unneccessary disk IO? + self.updateKeyInfo(self.hash, 'name', self.certName) def updateKey(self): """ @@ -190,6 +244,7 @@ class auth: self.cur.execute("UPDATE keys SET last_seen = strftime('%s') WHERE hash = ?", (self.hash, )) self.con.commit() + self.keysOutdated.append('last_seen') SUCCESS = 0 NAME_IN_USE = 1 @@ -218,6 +273,7 @@ class auth: # now the key is protected from autodeletion self.con.commit() self.username = username + self.updateKeyInfo(self.hash, 'user', uid) return self.SUCCESS @@ -273,6 +329,7 @@ class auth: self.cur.execute("UPDATE keys SET user = ? WHERE hash = ?", (res['id'], self.hash)) self.burnLink(res['name']) self.con.commit() + self.updateKeyInfo(self.hash, 'user', res['id']) self.username = res['name'] return self.SUCCESS else: @@ -288,12 +345,45 @@ class auth: 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 + if (hash in self.getKeys()): + self.cur.execute("DELETE FROM keys WHERE hash = ?", (hash, )) + self.con.commit() + del self.keys[hash] + return self.SUCCESS + + return self.NOT_FOUND + + def requestRename(self, hash): + """ + prepare for changing name of given key + """ + if (not self.username): + return None + + if (hash in self.getKeys()): + self.cur.execute("UPDATE users SET request_rename = ? WHERE name = ?", (hash, self.username)) + self.con.commit() + self.user['request_rename'] = hash + return self.SUCCESS + + return self.NOT_FOUND + + def renameKey(self, name): + """ + Change the display name of given key + """ + if (not self.username): + return None + + hash = self.userInfo('request_rename') + # key hash in database is probably good, but's better safe than sorry + if (hash in self.getKeys()): + self.cur.execute("UPDATE keys SET name = ? WHERE hash = ?", (name, hash)) + self.cur.execute("UPDATE users SET request_rename = NULL WHERE name = ?", (self.username, )) + self.con.commit() + self.updateKeyInfo(hash, 'name', name) + self.updateUserInfo('request_rename', None) + return self.SUCCESS return self.NOT_FOUND @@ -302,6 +392,11 @@ if (__name__ == '__main__'): import sys if (len(sys.argv) > 1): auth(sys.argv[1]) - print({"enableRegistration": auth.ENABLE_REGISTRATION}) + print({ + "enableRegistration": auth.ENABLE_REGISTRATION, + "linkExpire": auth.LINK_EXPIRE, + "anticExpire": auth.ANTIC_EXPIRE, + "debug": DEBUG + }) else: print('Database file not specified') \ No newline at end of file diff --git a/public/cgi/account/index.gmi b/public/cgi/account/index.gmi index 6262782..3bdf44e 100755 --- a/public/cgi/account/index.gmi +++ b/public/cgi/account/index.gmi @@ -10,12 +10,13 @@ if (not hash): # no CC print('60 Authentication is required\r\n') exit() +certName = os.environ.get('REMOTE_USER') print('20 text/gemini\r\n') from auth import auth auth = auth('data/data.db') -auth.passKey(hash) +auth.passKey(hash, certName) if (not auth.username): # mismatch @@ -28,8 +29,9 @@ else: print('Hello, {}!'.format(auth.username)) print('## Your keys') - for key in auth.getKeys(): - hash = key['hash'] + myKeys = auth.getKeys(['last_seen']) + for hash in myKeys: + key = myKeys[hash] lastSeen = datetime.fromtimestamp(key['last_seen']) current = hash == auth.hash @@ -43,6 +45,7 @@ else: print('hash:', hash) print('last seen:', lastSeen) + print('=> rename-request.gmi?{} rename'.format(auth.genAnticSRF() + hash)) if (not current): print('=> unlink.gmi?{} unlink'.format(auth.genAnticSRF() + hash)) @@ -59,4 +62,5 @@ else: print('Token {} will expire in {}:{}'.format(linkToken, zero(minutes), zero(seconds))) print('=> link.gmi?cancel cancel') else: + print('---') print('=> link.gmi link new key') \ No newline at end of file diff --git a/public/cgi/account/link.gmi b/public/cgi/account/link.gmi index 90f579a..8fb8e1c 100755 --- a/public/cgi/account/link.gmi +++ b/public/cgi/account/link.gmi @@ -10,10 +10,11 @@ if (not hash): # no CC print('60 Authentication is required\r\n') exit() +certName = os.environ.get('REMOTE_USER') from auth import auth auth = auth('data/data.db') -auth.passKey(hash) +auth.passKey(hash, certName) query = os.environ.get('QUERY_STRING') diff --git a/public/cgi/account/register.gmi b/public/cgi/account/register.gmi index 4d1320d..040c81e 100755 --- a/public/cgi/account/register.gmi +++ b/public/cgi/account/register.gmi @@ -10,10 +10,11 @@ if (not hash): # no CC print('60 Authentication is required\r\n') exit() +certName = os.environ.get('REMOTE_USER') from auth import auth auth = auth('data/data.db') -auth.passKey(hash) +auth.passKey(hash, certName) if (auth.username): # match diff --git a/public/cgi/account/rename-request.gmi b/public/cgi/account/rename-request.gmi new file mode 100755 index 0000000..572457a --- /dev/null +++ b/public/cgi/account/rename-request.gmi @@ -0,0 +1,68 @@ +#!/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() +certName = os.environ.get('REMOTE_USER') + +from auth import auth +auth = auth('data/data.db') +auth.passKey(hash, certName) + +if (not auth.username): + # mismatch + print('61 Unknown key\r\n') +else: + # match + query = os.environ.get('QUERY_STRING') + + if (not query): + # empty + from datetime import datetime + print('20 text/gemini\r\n') + print('Which key would you like to rename?') + + myKeys = auth.getKeys(['last_seen']) + for hash in myKeys: + key = myKeys['hash'] + lastSeen = datetime.fromtimestamp(key['last_seen']) + current = hash == auth.hash + + name = key['name'] + name = '"' + name + '"' if name else '[no name]' + + label = '=> rename-request.gmi?{} {}'.format(auth.genAnticSRF() + hash, name) + if (current): + label += ' (currently used)' + print(label) + + print('hash:', hash) + print('last seen:', lastSeen) + 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.requestRename(hash) + if (res == auth.SUCCESS): + print('30 rename.gmi\r\n') + elif (res == auth.NOT_FOUND): + print('20 text/gemini\r\n') + print('Failed to rename non-existing key, or key which does not belong to you.') + print('=> index.gmi back to home') + else: + print('40 Unknown error\r\n') + else: + print('50 Bad Antic SRF (security)\r\n') \ No newline at end of file diff --git a/public/cgi/account/rename.gmi b/public/cgi/account/rename.gmi new file mode 100755 index 0000000..968ea66 --- /dev/null +++ b/public/cgi/account/rename.gmi @@ -0,0 +1,42 @@ +#!/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() +certName = os.environ.get('REMOTE_USER') + +from auth import auth +auth = auth('data/data.db') +auth.passKey(hash, certName) + +if (not auth.username): + # mismatch + print('61 Unknown key\r\n') +else: + # match + name = os.environ.get('QUERY_STRING') + if (not name): + # empty + if (auth.userInfo('request_rename')): + # TODO: tell which key are you renaming + print('10 Choose new name for your key\r\n') + else: + print('30 rename-request.gmi\r\n') + else: + # string + res = auth.renameKey(name) + if (res == auth.SUCCESS): + print('30 index.gmi\r\n') + elif (res == auth.NOT_FOUND): + print('20 text/gemini\r\n') + print('Failed to rename non-existing key, or key which does not belong to you.') + print('=> index.gmi back to home') + else: + print('40 Unknown error\r\n') \ No newline at end of file diff --git a/public/cgi/account/unlink.gmi b/public/cgi/account/unlink.gmi index 257f0be..14c6738 100755 --- a/public/cgi/account/unlink.gmi +++ b/public/cgi/account/unlink.gmi @@ -10,10 +10,11 @@ if (not hash): # no CC print('60 Authentication is required\r\n') exit() +certName = os.environ.get('REMOTE_USER') from auth import auth auth = auth('data/data.db') -auth.passKey(hash) +auth.passKey(hash, certName) if (not auth.username): # mismatch diff --git a/public/cgi/index.gmi b/public/cgi/index.gmi index 48e116b..b5177bd 100755 --- a/public/cgi/index.gmi +++ b/public/cgi/index.gmi @@ -10,14 +10,16 @@ hash = os.environ.get('TLS_CLIENT_HASH') if (not hash): print('60 Authentication is required\r\n') exit() +certName = os.environ.get('REMOTE_USER') print('20 text/gemini\r\n') from auth import auth auth = auth('data/data.db') -auth.passKey(hash) +auth.passKey(hash, certName) print('Your hash:', auth.hash) +print('Your common name:', certName) if (auth.username): print('Your username:', auth.username) print('=> account/index.gmi manage your account')