initial commit
This commit is contained in:
commit
e6524dce00
|
@ -0,0 +1,11 @@
|
||||||
|
# Stargazer files
|
||||||
|
/stargazer
|
||||||
|
/stargazer.*
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
/certs
|
||||||
|
/data
|
||||||
|
/.config.ini
|
||||||
|
|
||||||
|
# Python files
|
||||||
|
__pycache__
|
|
@ -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
|
|
@ -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
|
|
@ -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')
|
|
@ -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')
|
|
@ -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')
|
|
@ -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')
|
|
@ -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')
|
|
@ -0,0 +1,3 @@
|
||||||
|
/ nothing special here, just a redirection to cgi application :) /
|
||||||
|
|
||||||
|
=> cgi/index.gmi
|
Reference in New Issue