initial commit

This commit is contained in:
Anedroid 2022-12-25 22:28:09 +01:00
commit e6524dce00
Signed by: anedroid
GPG Key ID: F149EE15E69C7F45
9 changed files with 519 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Stargazer files
/stargazer
/stargazer.*
# Data files
/certs
/data
/.config.ini
# Python files
__pycache__

12
config.ini Normal file
View File

@ -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

71
doc/authentication Normal file
View File

@ -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

232
lib/auth/__init__.py Normal file
View File

@ -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')

65
public/cgi/account/index.gmi Executable file
View File

@ -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')

57
public/cgi/account/link.gmi Executable file
View File

@ -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')

42
public/cgi/account/register.gmi Executable file
View File

@ -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')

26
public/cgi/index.gmi Executable file
View File

@ -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')

3
public/index.gmi Normal file
View File

@ -0,0 +1,3 @@
/ nothing special here, just a redirection to cgi application :) /
=> cgi/index.gmi