394 lines
12 KiB
Python
394 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
A security shield for protecting a number of small web applications.
|
|
"""
|
|
import json
|
|
import secrets
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
|
|
from aiohttp import web
|
|
import jinja2
|
|
import aiohttp_jinja2
|
|
from aiohttp_jinja2 import render_template
|
|
from passlib.hash import argon2
|
|
import asyncpg
|
|
import uvloop
|
|
|
|
import auth
|
|
import forms
|
|
import tools
|
|
import config
|
|
|
|
uvloop.install()
|
|
routes = web.RouteTableDef()
|
|
|
|
@routes.get('/', name='index')
|
|
@routes.post('/', name='index')
|
|
@auth.auth_required
|
|
async def index(request):
|
|
"""The index page."""
|
|
if request.method == 'POST':
|
|
data = await request.post()
|
|
form = data.get('form_name')
|
|
|
|
forms_ = {
|
|
'invite_user': forms.invite_user,
|
|
'change_user_perms': forms.change_user_perms,
|
|
'new_app': forms.new_app,
|
|
'change_password': forms.change_password,
|
|
'delete_key': forms.delete_key,
|
|
'delete_session': forms.delete_session,
|
|
}
|
|
|
|
if not forms_.get(form):
|
|
errors = {'main': f"Unknown form id: {form}"}
|
|
else:
|
|
errors = await forms_[form](request)
|
|
|
|
async with request.app['pool'].acquire() as conn:
|
|
avail_sites = await conn.fetch(
|
|
"SELECT app_info.name, app_info.url "
|
|
"FROM app_info LEFT JOIN app_user "
|
|
"ON (app_info.id = app_user.app_id) "
|
|
"WHERE app_user.user_id = $1",
|
|
request['session']['id'])
|
|
if request['session']['admin']:
|
|
apps = await conn.fetch(
|
|
"SELECT id, name FROM app_info ORDER BY id ASC")
|
|
users = await conn.fetch(
|
|
"SELECT id, username FROM user_info ORDER BY id ASC")
|
|
user_perms = await conn.fetch(
|
|
"SELECT user_info.id, app_user.app_id "
|
|
"FROM user_info LEFT JOIN app_user "
|
|
"ON (user_info.id = app_user.user_id) ")
|
|
fido2_keys = await conn.fetch(
|
|
"SELECT * FROM user_credential WHERE user_id = $1",
|
|
request['session']['id'])
|
|
active_sessions = await conn.fetch(
|
|
"SELECT id, ip_address, date_created, last_used FROM user_session "
|
|
"WHERE user_id = $1 AND expires > NOW() "
|
|
"ORDER BY last_used DESC",
|
|
request['session']['id'])
|
|
|
|
if request['session']['admin']:
|
|
user_perms_dict = {}
|
|
for user in users:
|
|
lst = [up for up in user_perms if up['id'] == user['id']]
|
|
d = {app['id']: False for app in apps}
|
|
for perm in lst:
|
|
if perm['app_id'] not in d:
|
|
continue
|
|
d[perm['app_id']] = True
|
|
user_perms_dict[user['id']] = d
|
|
users_dict = {user['id']: user['username'] for user in users}
|
|
apps_dict = {app['id']: app['name'] for app in apps}
|
|
user_perms_json = json.dumps(user_perms_dict)
|
|
|
|
return render_template("index.html", request, locals())
|
|
|
|
|
|
@routes.get('/login', name='login')
|
|
@routes.post('/login', name='login')
|
|
async def login(request):
|
|
"""Handle login."""
|
|
login_failed = False
|
|
if request.method == 'GET':
|
|
return render_template("login.html", request, locals())
|
|
|
|
form = await request.post()
|
|
username = form.get('username')
|
|
password = form.get('password')
|
|
|
|
async with request.app['pool'].acquire() as conn:
|
|
user_info = await conn.fetchrow(
|
|
"SELECT * FROM user_info WHERE username = $1 AND active = TRUE",
|
|
username)
|
|
if user_info:
|
|
has_cred = await conn.fetchrow(
|
|
"SELECT EXISTS(SELECT id FROM user_credential "
|
|
"WHERE user_id = $1)",
|
|
user_info['id'])
|
|
|
|
if not user_info:
|
|
login_failed = True
|
|
return render_template("login.html", request, locals())
|
|
|
|
if has_cred['exists'] and user_info['passwordless']:
|
|
url_prefix = config.url_prefix
|
|
resp = render_template("login_key.html", request, locals())
|
|
resp.set_cookie(
|
|
'userid',
|
|
user_info['id'],
|
|
domain=config.server_domain,
|
|
secure=True,
|
|
httponly=True)
|
|
#samesite='strict')
|
|
return resp
|
|
|
|
if argon2.verify(password, user_info['password_hash']):
|
|
if has_cred['exists']:
|
|
url_prefix = config.url_prefix
|
|
resp = render_template("login_key.html", request, locals())
|
|
resp.set_cookie(
|
|
'userid',
|
|
user_info['id'],
|
|
domain=config.server_domain,
|
|
secure=True,
|
|
httponly=True)
|
|
#samesite='strict')
|
|
return resp
|
|
|
|
url = request.cookies.get('redirect')
|
|
if not url:
|
|
url = request.app.router['index'].url_for()
|
|
resp = web.HTTPFound(location=url)
|
|
resp.set_cookie('redirect', '', domain=config.server_domain, max_age=0)
|
|
resp.set_cookie(
|
|
'userid',
|
|
user_info['id'],
|
|
domain=config.server_domain,
|
|
max_age=30*24*60*60,
|
|
secure=True,
|
|
httponly=True)
|
|
#samesite='strict')
|
|
sid = secrets.token_urlsafe(64)
|
|
ip_address = request.headers['X-Real-IP']
|
|
async with request.app['pool'].acquire() as conn:
|
|
await conn.execute(
|
|
"INSERT INTO user_session (user_id, id, ip_address)"
|
|
"VALUES ($1, $2, $3)",
|
|
user_info['id'],
|
|
sid,
|
|
ip_address)
|
|
resp.set_cookie(
|
|
'session',
|
|
sid,
|
|
domain=config.server_domain,
|
|
max_age=30*24*60*60,
|
|
secure=True,
|
|
httponly=True)
|
|
#samesite='strict')
|
|
raise resp
|
|
else:
|
|
login_failed = True
|
|
return render_template("login.html", request, locals())
|
|
|
|
|
|
@routes.get('/register', name='register')
|
|
@routes.post('/register', name='register')
|
|
async def register(request):
|
|
"""Register new accounts."""
|
|
confirm_token = request.query.get('confirm')
|
|
if confirm_token:
|
|
async with request.app['pool'].acquire() as conn:
|
|
confirm = await conn.fetchrow(
|
|
"SELECT * FROM email_confirmation WHERE token = $1",
|
|
confirm_token)
|
|
if confirm:
|
|
async with request.app['pool'].acquire() as conn:
|
|
await conn.execute(
|
|
"UPDATE user_info SET active = TRUE WHERE id = $1",
|
|
confirm['user_id'])
|
|
await conn.execute(
|
|
"DELETE FROM email_confirmation WHERE user_id = $1",
|
|
confirm['user_id'])
|
|
message = "Success!" # TODO: more informative message
|
|
else:
|
|
message = "Invalid confirmation token."
|
|
return render_template("register_result.html", request, locals())
|
|
|
|
invite_token = request.query.get('invite')
|
|
if invite_token:
|
|
async with request.app['pool'].acquire() as conn:
|
|
invite = await conn.fetchrow(
|
|
"SELECT * FROM invite WHERE token = $1",
|
|
invite_token)
|
|
if invite:
|
|
if request.method == 'GET':
|
|
errors = {}
|
|
return render_template("register.html", request, locals())
|
|
|
|
form = await request.post()
|
|
errors = await tools.validate_register(request, form)
|
|
if any(errors.values()):
|
|
return render_template("register.html", request, locals())
|
|
|
|
username = form.get('username')
|
|
email = form.get('email')
|
|
password = form.get('password')
|
|
pw_hash = argon2.hash(password)
|
|
async with request.app['pool'].acquire() as conn:
|
|
user = await conn.fetchrow(
|
|
"INSERT INTO user_info (username, email, password_hash) "
|
|
"VALUES ($1, $2, $3) RETURNING id",
|
|
username, email, pw_hash)
|
|
await conn.execute(
|
|
"DELETE FROM invite WHERE token = $1",
|
|
invite_token)
|
|
await tools.send_confirmation(request, user['id'], email)
|
|
message = "An email has been sent." # TODO: more better
|
|
else:
|
|
message = "Invalid invitation token."
|
|
return render_template("register_result.html", request, locals())
|
|
|
|
message = "Secret club only."
|
|
return render_template("register_result.html", request, locals())
|
|
|
|
|
|
@routes.get('/add_key', name='add_key')
|
|
@auth.auth_required
|
|
async def add_key(request):
|
|
"""Add a new security key."""
|
|
url_prefix = config.url_prefix
|
|
return render_template("register_key.html", request, locals())
|
|
|
|
|
|
@routes.get('/get_session', name='get_session')
|
|
async def get_session(request):
|
|
"""Returns a user's application session."""
|
|
# TODO: only allow LAN IPs
|
|
app_id = request.query.get('app_id')
|
|
app_key = request.query.get('app_key')
|
|
user_id = request.query.get('userid')
|
|
user_sid = request.query.get('session')
|
|
|
|
try:
|
|
app_id = int(app_id)
|
|
user_id = int(user_id)
|
|
assert all((app_id, app_key, user_id, user_sid))
|
|
except (ValueError, AssertionError):
|
|
return web.json_response({'error': "Invalid credentials."})
|
|
|
|
conn = await request.app['pool'].acquire()
|
|
app = await conn.fetchrow("SELECT * FROM app_info WHERE id = $1", app_id)
|
|
if app:
|
|
if argon2.verify(app_key, app['key_hash']):
|
|
session = await conn.fetchrow(
|
|
"SELECT * FROM user_session "
|
|
"WHERE user_id = $1 AND id = $2 AND expires > NOW()",
|
|
user_id, user_sid)
|
|
if session:
|
|
data = await conn.fetchrow(
|
|
"SELECT user_info.username, app_user.session_data "
|
|
"FROM user_info LEFT JOIN app_user "
|
|
"ON (user_info.id = app_user.user_id) "
|
|
"WHERE user_info.id = $1 AND app_user.app_id = $2",
|
|
session['user_id'], app['id'])
|
|
|
|
tz = session['last_used'].tzinfo
|
|
delta = datetime.now(tz) - session['last_used']
|
|
if delta.seconds > 600:
|
|
await conn.execute(
|
|
"UPDATE user_session SET last_used = NOW(), "
|
|
"expires = NOW() + INTERVAL '30 DAYS' "
|
|
"WHERE user_id = $1 AND id = $2",
|
|
user_id, user_sid)
|
|
await conn.close()
|
|
|
|
if not data: # user not permitted to use app
|
|
error = {'error': "you do not have permission to "
|
|
"access to this application."}
|
|
return web.json_response(error)
|
|
|
|
data_meta = dict(data)
|
|
data_meta['last_used'] = session['last_used'].isoformat()
|
|
data_meta['user_sid'] = user_sid
|
|
data_meta['user_id'] = user_id
|
|
session_data = data_meta.pop('session_data')
|
|
data = {
|
|
'meta': data_meta,
|
|
'session_data': json.loads(session_data)
|
|
}
|
|
|
|
return web.json_response(data)
|
|
else:
|
|
error = {'error': "User ID or Session ID invalid."}
|
|
else:
|
|
error = {'error': "App ID or Key invalid."}
|
|
else:
|
|
error = {'error': "App ID or Key invalid."}
|
|
await conn.close()
|
|
return web.json_response(error)
|
|
|
|
|
|
@routes.post('/set_session', name='set_session')
|
|
async def set_session(request):
|
|
"""Allows an application to set a user app session."""
|
|
# TODO: only allow LAN IPs
|
|
app_id = request.query.get('app_id')
|
|
app_key = request.query.get('app_key')
|
|
user_id = request.query.get('userid')
|
|
user_sid = request.query.get('session')
|
|
|
|
try:
|
|
app_id = int(app_id)
|
|
user_id = int(user_id)
|
|
assert all((app_id, app_key, user_id, user_sid))
|
|
except (ValueError, AssertionError):
|
|
return web.json_response({'error': "Invalid credentials."})
|
|
|
|
conn = await request.app['pool'].acquire()
|
|
app = await conn.fetchrow("SELECT * FROM app_info WHERE id = $1", app_id)
|
|
if app:
|
|
if argon2.verify(app_key, app['key_hash']):
|
|
session = await conn.fetchrow(
|
|
"SELECT * FROM user_session "
|
|
"WHERE user_id = $1 AND id = $2 AND expires > NOW()",
|
|
user_id, user_sid)
|
|
if session:
|
|
session_data = await request.text()
|
|
# TODO: error handling, verify json
|
|
await conn.execute(
|
|
"UPDATE app_user SET session_data = $1 "
|
|
"WHERE user_id = $2 AND app_id = $3",
|
|
session_data, user_id, app_id)
|
|
await conn.close()
|
|
return web.json_response({'success': True})
|
|
else:
|
|
error = {'error': "User ID or Session ID invalid."}
|
|
else:
|
|
error = {'error': "App ID or Key invalid."}
|
|
else:
|
|
error = {'error': "App ID or Key invalid."}
|
|
await conn.close()
|
|
return web.json_response(error)
|
|
|
|
|
|
@routes.get('/logout', name='logout')
|
|
@auth.auth_required
|
|
async def logout(request):
|
|
"""Deletes the users session cookie."""
|
|
async with request.app['pool'].acquire() as conn:
|
|
await conn.execute(
|
|
"DELETE FROM user_session "
|
|
"WHERE id = $1 AND user_id = $2",
|
|
request.cookies.get('session'), request['session']['id'])
|
|
login_url = request.app.router['login'].url_for()
|
|
resp = web.HTTPFound(location=login_url)
|
|
resp.set_cookie('userid', '', domain=config.server_domain, max_age=0)
|
|
resp.set_cookie('session', '', domain=config.server_domain, max_age=0)
|
|
raise resp
|
|
|
|
|
|
async def init_app():
|
|
"""Initializes the application."""
|
|
app = web.Application()
|
|
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('templates'))
|
|
app['pool'] = await asyncpg.create_pool(**config.db)
|
|
|
|
async with app['pool'].acquire() as conn:
|
|
with open('buckler.sql', 'r') as file:
|
|
await conn.execute(file.read())
|
|
|
|
app.router.add_routes(routes)
|
|
app.router.add_routes(auth.routes)
|
|
|
|
app_wrap = web.Application()
|
|
app_wrap.add_subapp(config.url_prefix, app)
|
|
return app
|
|
|
|
if __name__ == "__main__":
|
|
app = init_app()
|
|
web.run_app(app, host='0.0.0.0', port=5400)
|