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