Buckler/buckler.py

353 lines
10 KiB
Python
Raw Normal View History

2019-09-14 18:36:23 -04:00
#!/usr/bin/env python3
"""
A security shield for protecting a number of small web applications.
"""
2019-09-19 20:49:40 -04:00
import json
2019-09-15 19:40:58 -04:00
import secrets
2019-09-16 20:18:50 -04:00
from datetime import datetime
2019-09-20 19:24:12 -04:00
from collections import defaultdict
2019-09-15 19:40:58 -04:00
2019-09-14 18:36:23 -04:00
from aiohttp import web
import jinja2
import aiohttp_jinja2
from aiohttp_jinja2 import render_template
2019-09-15 19:40:58 -04:00
from passlib.hash import argon2
import asyncpg
2019-09-16 20:18:50 -04:00
import uvloop
2019-09-14 18:36:23 -04:00
2019-09-24 19:34:20 -04:00
import auth
2019-09-29 13:25:07 -04:00
import forms
import tools
2019-09-14 18:36:23 -04:00
import config
2019-09-16 20:18:50 -04:00
uvloop.install()
2019-09-14 18:36:23 -04:00
routes = web.RouteTableDef()
2019-10-17 13:37:35 -04:00
@routes.get('/', name='index')
@routes.post('/', name='index')
2019-09-24 19:34:20 -04:00
@auth.auth_required
2019-09-14 18:36:23 -04:00
async def index(request):
"""The index page."""
2019-10-17 13:47:25 -04:00
if request.method == 'POST':
data = await request.post()
form = data.get('form_name')
forms_ = {
'invite_user': forms.invite_user,
2019-10-17 20:58:20 -04:00
'change_user_perms': forms.change_user_perms,
'new_app': forms.new_app,
2019-10-17 13:47:25 -04:00
'change_password': forms.change_password,
'delete_key': forms.delete_key,
2020-08-14 23:47:25 -04:00
'delete_session': forms.delete_session,
2019-10-17 13:47:25 -04:00
}
if not forms_.get(form):
2020-08-14 23:47:25 -04:00
errors = {'main': f"Unknown form id: {form}"}
2019-10-17 13:47:25 -04:00
else:
errors = await forms_[form](request)
2019-09-20 19:24:12 -04:00
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(
2019-09-26 13:33:06 -04:00
"SELECT id, name FROM app_info")
2019-09-20 19:24:12 -04:00
user_perms = await conn.fetch(
"SELECT user_info.id, user_info.username, app_user.app_id "
"FROM user_info LEFT JOIN app_user "
"ON (user_info.id = app_user.user_id)")
2019-09-24 19:34:20 -04:00
fido2_keys = await conn.fetch(
"SELECT * FROM user_credential WHERE user_id = $1",
request['session']['id'])
2019-09-25 15:00:29 -04:00
active_sessions = await conn.fetch(
2019-09-26 19:15:46 -04:00
"SELECT id, ip_address, date_created, last_used FROM user_session "
2020-08-14 23:47:25 -04:00
"WHERE user_id = $1 AND expires > NOW() "
"ORDER BY last_used DESC",
2019-09-25 15:00:29 -04:00
request['session']['id'])
2019-09-20 19:24:12 -04:00
if request['session']['admin']:
2019-09-26 14:39:48 -04:00
users = defaultdict(lambda: {app['name']: False for app in apps})
2019-09-20 19:24:12 -04:00
for user_perm in user_perms:
2019-09-26 13:33:06 -04:00
index = tools.find_dict(apps, 'id', user_perm['app_id'])
if index != -1:
2019-09-26 14:39:48 -04:00
users[user_perm['username']][apps[index]['name']] = True
2019-09-20 19:24:12 -04:00
users_json = json.dumps(users)
2019-09-29 13:25:07 -04:00
return render_template("index.html", request, locals())
2019-09-24 19:34:20 -04:00
2019-10-17 13:37:35 -04:00
@routes.get('/login', name='login')
@routes.post('/login', name='login')
2019-09-15 19:40:58 -04:00
async def login(request):
"""Handle login."""
2019-09-19 20:49:40 -04:00
login_failed = False
2019-09-15 19:40:58 -04:00
if request.method == 'GET':
return render_template("login.html", request, locals())
2019-09-19 20:49:40 -04:00
form = await request.post()
username = form.get('username')
password = form.get('password')
2019-09-15 19:40:58 -04:00
async with request.app['pool'].acquire() as conn:
user_info = await conn.fetchrow(
2019-10-17 13:39:14 -04:00
"SELECT * FROM user_info WHERE username = $1 AND active = TRUE",
2019-09-15 19:40:58 -04:00
username)
2019-09-24 19:34:20 -04:00
if user_info:
has_cred = await conn.fetchrow(
"SELECT EXISTS(SELECT id FROM user_credential "
"WHERE user_id = $1)",
user_info['id'])
2019-09-15 19:40:58 -04:00
if not user_info:
2019-09-19 20:49:40 -04:00
login_failed = True
2019-09-15 19:40:58 -04:00
return render_template("login.html", request, locals())
2019-09-24 19:34:20 -04:00
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'])
return resp
2019-09-15 19:40:58 -04:00
if argon2.verify(password, user_info['password_hash']):
2019-09-24 19:34:20 -04:00
if has_cred['exists']:
url_prefix = config.url_prefix
resp = render_template("login_key.html", request, locals())
resp.set_cookie('userid', user_info['id'])
return resp
2019-09-20 19:24:12 -04:00
url = request.cookies.get('redirect')
if not url:
url = request.app.router['index'].url_for()
resp = web.HTTPFound(location=url)
resp.set_cookie('redirect', '', max_age=0)
2019-09-16 20:18:50 -04:00
resp.set_cookie(
'userid',
user_info['id'],
max_age=30*24*60*60,
secure=True,
httponly=True)
2019-09-15 19:40:58 -04:00
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)
2019-09-16 20:18:50 -04:00
resp.set_cookie(
'session',sid,
max_age=30*24*60*60,
secure=True,
httponly=True)
2019-09-15 19:40:58 -04:00
raise resp
else:
2019-09-19 20:49:40 -04:00
login_failed = True
2019-09-15 19:40:58 -04:00
return render_template("login.html", request, locals())
2019-10-17 13:37:35 -04:00
@routes.get('/register', name='register')
@routes.post('/register', name='register')
2019-09-15 19:40:58 -04:00
async def register(request):
"""Register new accounts."""
2019-09-19 20:49:40 -04:00
confirm_token = request.query.get('confirm')
if confirm_token:
2019-09-16 20:18:50 -04:00
async with request.app['pool'].acquire() as conn:
2019-09-19 20:49:40 -04:00
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)
2019-09-19 20:49:40 -04:00
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)
2019-09-19 20:49:40 -04:00
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())
2019-10-17 13:37:35 -04:00
@routes.get('/add_key', name='add_key')
2019-09-24 19:34:20 -04:00
@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())
2019-10-17 13:37:35 -04:00
@routes.get('/get_session', name='get_session')
2019-09-19 20:49:40 -04:00
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(
2019-09-19 20:49:40 -04:00
"SELECT * FROM user_session "
"WHERE user_id = $1 AND id = $2 AND expires > NOW()",
user_id, user_sid)
2019-09-19 20:49:40 -04:00
if session:
data = await conn.fetchrow(
"SELECT user_info.username, app_user.session_data "
2019-09-25 19:46:57 -04:00
"FROM user_info LEFT JOIN app_user "
"ON (user_info.id = app_user.user_id) "
2019-09-19 20:49:40 -04:00
"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)
2019-09-19 20:49:40 -04:00
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)
2019-09-19 20:49:40 -04:00
data_meta = dict(data)
2019-09-25 19:46:57 -04:00
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')
2019-09-19 20:49:40 -04:00
data = {
'meta': data_meta,
2019-09-25 19:46:57 -04:00
'session_data': json.loads(session_data)
}
2019-09-19 20:49:40 -04:00
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)
2019-10-17 13:37:35 -04:00
@routes.post('/set_session', name='set_session')
2019-09-19 20:49:40 -04:00
async def set_session(request):
"""Allows an application to set a user app session."""
2019-09-25 19:46:57 -04:00
# 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)
2019-09-15 19:40:58 -04:00
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)
2019-09-24 19:34:20 -04:00
app.router.add_routes(auth.routes)
2019-10-17 13:37:35 -04:00
app_wrap = web.Application()
app_wrap.add_subapp(config.url_prefix, app)
2019-09-15 19:40:58 -04:00
return app
2019-09-14 18:36:23 -04:00
if __name__ == "__main__":
2019-09-15 19:40:58 -04:00
app = init_app()
2019-09-14 18:36:23 -04:00
web.run_app(app, host='0.0.0.0', port=5400)