commit e6524dce0084957779a05639c9db7555d25fb046 Author: Anedroid Date: Sun Dec 25 22:28:09 2022 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4b165c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Stargazer files +/stargazer +/stargazer.* + +# Data files +/certs +/data +/.config.ini + +# Python files +__pycache__ \ No newline at end of file diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..053482d --- /dev/null +++ b/config.ini @@ -0,0 +1,12 @@ +# example stargazer config, nothing special here +listen = 127.0.0.1 + +[:tls] +store = certs + +[localhost] +root = public + +[localhost:/cgi] +root = public +cgi = on diff --git a/doc/authentication b/doc/authentication new file mode 100644 index 0000000..cac9297 --- /dev/null +++ b/doc/authentication @@ -0,0 +1,71 @@ +There are keys and users. Every key must be linked to exactly one user, otherwise it's deleted. +You can link a key by either creating new user (by specifying a unique name) or linking with existing account. +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. + +Access points: +/cgi - cgi scripts root + /index.gmi - main entry point + /account - managing your account preferences + /index.gmi - menu + no CC - request CC + mismatch - invalid CC + match - list keys and links to unlink.gmi, active tokens and links to cancel + /register.gmi - register new account + no CC - request CC + mismatch + empty - choose your name + string - verify & success message | name already in use + match - already logged in + /link.gmi - link new key to your account + no CC - request CC + mismatch + empty - enter your token + token - verify & display success message | warning + match + empty - here's your token + token - tip: open this link on new device + cancel - burn token & linking key cancelled + string - what are you trying to do? + /unlink.gmi - delete your key + no CC - request CC + mismatch - invalid CC + match + empty - redirect to index.gmi + anticsrf+hash - verify & redirect to index.gmi | warning + /delete.gmi - delete your account + no CC - request CC + mismatch - invalid CC + match + empty - mark account as request delete, ask for confirmation + string - verify & delete | renew challenge + cancel - burn token & account deletion cancelled + +Database scheme: +file data/data.db +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) UNIQUE, + link_token VARCHAR(16) UNIQUE, + link_token_time INTEGER, + request_delete VARCHAR(16), + request_delete_time INTEGER, + anticsrf VARCHAR(4), + anticsrf_time INTEGER +) +CREATE TABLE IF NOT EXISTS keys ( + hash VARCHAR(255) PRIMARY KEY, + user INTEGER, + last_seen INTEGER NOT NULL DEFAULT (strftime('%s')), + name VARCHAR(255), + FOREIGN KEY (user) REFERENCES users (id) + ON DELETE CASCADE +) + +TODO: +- passwords +- registration captcha +- 2 factor authentication +- notification feed \ No newline at end of file diff --git a/lib/auth/__init__.py b/lib/auth/__init__.py new file mode 100644 index 0000000..f91feba --- /dev/null +++ b/lib/auth/__init__.py @@ -0,0 +1,232 @@ +import sqlite3 + +def dict_factory(cursor, row): + fields = [column[0] for column in cursor.description] + return {key: value for key, value in zip(fields, row)} + +def generateToken(length): + import random + token = '' + for i in range(0, length): + token += random.choice('1234567890abcdefghijklmnopqrstuvwxyz') + return token + +class auth: + ENABLE_REGISTRATION = True + LINK_EXPIRE = 100*60 + + 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} + + def __init__(self, dbFile): + """ + database auto-creation, garbage collection + """ + self.con = sqlite3.connect(dbFile) + self.con.row_factory = dict_factory + self.cur = self.con.cursor() + + self.cur.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) UNIQUE, + link_token VARCHAR(16) UNIQUE, + link_token_time INTEGER, + request_delete VARCHAR(16), + request_delete_time INTEGER, + anticsrf VARCHAR(4), + anticsrf_time INTEGER + ) + """) + self.cur.execute(""" + CREATE TABLE IF NOT EXISTS keys ( + hash VARCHAR(255) PRIMARY KEY, + user INTEGER, + last_seen INTEGER NOT NULL DEFAULT (strftime('%s')), + name VARCHAR(255), + FOREIGN KEY (user) REFERENCES users (id) + ON DELETE CASCADE + ) + """) + + self.garbageCollector() + + def garbageCollector(self): + """ + delete all unlinked keys + """ + 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 + """ + self.hash = hash + + key = self.fetchKey() + 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): + """ + 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] + + 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] + + 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, )) + # 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 + + 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)) + # now the key is protected from autodeletion + self.con.commit() + 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 + + while not token: + token = generateToken(16) + res = self.cur.execute("SELECT * FROM users WHERE link_token = ?", (token, )) + if (res.fetchone()): + tokenSet = 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 + + return token + + def burnLink(self, username): + """ + force link token expiration + """ + self.cur.execute("UPDATE users SET link_token = NULL, link_token_time = NULL WHERE name = ?", (username, )) + + def link(self, token): + """ + link current key to user of given token + """ + if (not self.hash): + return None + + res = self.cur.execute("SELECT id, name FROM users WHERE link_token = ?", (token, )) + res = res.fetchone() + if (res): + self.cur.execute("UPDATE keys SET user = ? WHERE hash = ?", (res['id'], self.hash)) + self.burnLink(res['name']) + self.con.commit() + self.username = res['name'] + return auth.SUCCESS + else: + return auth.BAD_TOKEN + + +if (__name__ == '__main__'): + import sys + if (len(sys.argv) > 1): + auth(sys.argv[1]) + print({"enableRegistration": auth.ENABLE_REGISTRATION}) + 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 new file mode 100755 index 0000000..a0bb068 --- /dev/null +++ b/public/cgi/account/index.gmi @@ -0,0 +1,65 @@ +#!/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() + +print('20 text/gemini\r\n') + +from auth import auth +auth = auth('data/data.db') +auth.passKey(hash) + +if (not auth.username): + # mismatch + print('=> register.gmi register') + print('=> link.gmi link existing account') +else: + # match + from datetime import datetime + + print('Hello, {}!'.format(auth.username)) + print('## Your keys') + + for key in auth.getKeys(): + hash = key['hash'] + lastSeen = datetime.fromtimestamp(key['last_seen']) + current = hash == auth.hash + + name = key['name'] + if (name): + name = '"' + name + '"' + else: + name = '[no name]' + + txt = '* ' + name + if (current): + txt += ' (currently used)' + print(txt) + + print('hash:', hash) + print('last seen:', lastSeen) + + 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 + # TODO: calibration - it displays 00:00 for one second + def zero(n): + return str(n) if n>10 else '0'+str(n) + print('### Link new key') + print('Token {} will expire in {}:{}'.format(linkToken, zero(minutes), zero(seconds))) + print('=> link.gmi?cancel cancel') + else: + 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 new file mode 100755 index 0000000..ec25a21 --- /dev/null +++ b/public/cgi/account/link.gmi @@ -0,0 +1,57 @@ +#!/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) + +query = os.environ.get('QUERY_STRING') + +if (auth.username): + # match + if (not query): + # empty + from datetime import datetime + print('20 text/gemini\r\n') + + token = auth.requestLink() + 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') + print('=> ?cancel cancel') + elif (query == 'cancel'): + # cancel + auth.requestLink(cancel=True) + print('30 index.gmi\r\n') + elif (query == auth.userInfo('link_token')): + 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') +else: + # mismatch + if (not query): + # empty + print('10 Enter your token\r\n') + else: + # token + res = auth.link(query) + if (res == auth.SUCCESS): + print('20 text/gemini\r\n') + print('Successfully linked to {}!'.format(auth.username)) + 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.') + else: + print('40 Unknown error\r\n') \ No newline at end of file diff --git a/public/cgi/account/register.gmi b/public/cgi/account/register.gmi new file mode 100755 index 0000000..e0e1c15 --- /dev/null +++ b/public/cgi/account/register.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() + +from auth import auth +auth = auth('data/data.db') +auth.passKey(hash) + +if (auth.username): + # match + print('20 text/gemini\r\n') + print('Already logged in as', auth.username) + print('=> index.gmi back to home') +else: + # mismatch + if (not auth.ENABLE_REGISTRATION): + print('50 Registration is disabled\r\n') + exit() + + username = os.environ.get('QUERY_STRING') + if (not username): + # empty + print('10 Choose your name\r\n') + else: + # string + res = auth.registerUser(username) + if (res == auth.SUCCESS): + print('31 index.gmi\r\n') + elif (res == auth.NAME_IN_USE): + print('10 Chose your name (name already in use)\r\n') + # Skipped ACTION_DISABLED because we already checked that + else: + print('40 Unknown error\r\n') \ No newline at end of file diff --git a/public/cgi/index.gmi b/public/cgi/index.gmi new file mode 100755 index 0000000..e94f9e6 --- /dev/null +++ b/public/cgi/index.gmi @@ -0,0 +1,26 @@ +#!/usr/bin/python3 +# This is just a little example of home page, change it as you see fit. +import os +import sys + +os.chdir('..') +sys.path.append('lib') + +hash = os.environ.get('TLS_CLIENT_HASH') +if (not hash): + print('60 Authentication is required\r\n') + exit() + +print('20 text/gemini\r\n') + +from auth import auth +auth = auth('data/data.db') +auth.passKey(hash) + +print('Your hash:', auth.hash) +if (auth.username): + print('Your username:', auth.username) + print('=> account/index.gmi Manage your account') +else: + print('=> account/register.gmi register') + print('=> link.gmi link existing account') \ No newline at end of file diff --git a/public/index.gmi b/public/index.gmi new file mode 100644 index 0000000..f55dd80 --- /dev/null +++ b/public/index.gmi @@ -0,0 +1,3 @@ +/ nothing special here, just a redirection to cgi application :) / + +=> cgi/index.gmi \ No newline at end of file