diff --git a/README.md b/README.md index 6938d97..d7278c2 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,19 @@ A security shield for protecting a number of small web applications. ## Requirements Python 3.7+ PostgreSQL 11.5+ -Python packages: `gunicorn aiohttp aiohttp_jinja2 psycopg2 passlib argon2_cffi` +Python packages: `gunicorn aiohttp aiohttp_jinja2 asyncpg passlib argon2_cffi` ## Install +``` +$ psql +postgres=# CREATE DATABASE "buckler"; +postgres=# CREATE USER "buckler" WITH PASSWORD 'password'; +postgres=# ALTER ROLE "buckler" SET client_encoding TO 'utf8'; +postgres=# ALTER ROLE "buckler" SET default_transaction_isolation TO 'read committed'; +postgres=# ALTER ROLE "buckler" SET timezone TO 'UTC'; +postgres=# GRANT ALL PRIVILEGES ON DATABASE "buckler" TO "buckler"; +postgres=# \q +``` 1. Get on the floor 2. Walk the dinosaur diff --git a/buckler.py b/buckler.py index 451aa2c..8d6950e 100644 --- a/buckler.py +++ b/buckler.py @@ -2,24 +2,132 @@ """ A security shield for protecting a number of small web applications. """ +import secrets +import functools + from aiohttp import web import jinja2 import aiohttp_jinja2 from aiohttp_jinja2 import render_template +from passlib.hash import argon2 +import asyncpg import config -app = web.Application() -aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('templates')) - routes = web.RouteTableDef() -@routes.get(config.url_prefix + "/", name='index') +def auth_required(func): + """ + Wrapper for views with should be protected by authtication. + """ + @functools.wraps(func) + async def wrapper(request, *args, **kwargs): + login_url = request.app.router['login'].url_for() + sid = request.cookies.get('session') + try: + userid = int(request.cookies.get('userid')) + except ValueError: + userid = None + if not sid or not userid: + raise web.HTTPFound(location=login_url) + async with request.app['pool'].acquire() as conn: + sessions = await conn.fetch( + "SELECT * FROM user_session " + "WHERE user_id = $1", + userid) + if sid in [s.get('id') for s in sessions]: + return await func(request, *args, **kwargs) + else: + raise web.HTTPFound(location=login_url) + return wrapper + + +@routes.get(config.url_prefix + '/', name='index') +@auth_required async def index(request): """The index page.""" return render_template("index.html", request, locals()) -app.router.add_routes(routes) + +@routes.get(config.url_prefix + '/login', name='login') +@routes.post(config.url_prefix + '/login', name='login') +async def login(request): + """Handle login.""" + if request.method == 'GET': + return render_template("login.html", request, locals()) + + data = await request.post() + username = data.get('username') + password = data.get('password') + + async with request.app['pool'].acquire() as conn: + user_info = await conn.fetchrow( + "SELECT * FROM user_info WHERE username = $1", + username) + + if not user_info: + return render_template("login.html", request, locals()) + + if argon2.verify(password, user_info['password_hash']): + index_url = request.app.router['index'].url_for() + resp = web.HTTPFound(location=index_url) + resp.set_cookie('userid', user_info['id']) + 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) + raise resp + else: + return render_template("login.html", request, locals()) + + +@routes.get(config.url_prefix + '/register', name='register') +@routes.post(config.url_prefix + '/register', name='register') +async def register(request): + """Register new accounts.""" + if request.method == 'GET': + return render_template("register.html", request, locals()) + + data = await request.post() + username = data.get('username') + email = data.get('email') + password = data.get('password') + password_verify = data.get('password_verify') + + if password != password_verify: + raise web.HTTPNotAcceptable + + pw_hash = argon2.hash(password) + async with request.app['pool'].acquire() as conn: + await conn.execute( + "INSERT INTO user_info (username, email, password_hash) " + "VALUES ($1, $2, $3)", + username, + email, + pw_hash) + login_url = request.app.router['login'].url_for() + raise web.HTTPFound(location=login_url) + + +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) + return app if __name__ == "__main__": + app = init_app() web.run_app(app, host='0.0.0.0', port=5400) diff --git a/buckler.sql b/buckler.sql new file mode 100644 index 0000000..7e6da82 --- /dev/null +++ b/buckler.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS user_info ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + email TEXT NOT NULL, + password_hash TEXT +); + +CREATE TABLE IF NOT EXISTS user_session ( + user_id INTEGER references user_info(id), + id TEXT PRIMARY KEY, + ip_address TEXT NOT NULL +); diff --git a/config.py.template b/config.py.template index d9524ed..406b78a 100644 --- a/config.py.template +++ b/config.py.template @@ -1,7 +1,16 @@ #!/usr/bin/env python3 """ Configuration settings for Buckler. -url_prefix` is the root path you wish app to reside at +`url_prefix` is the root path you wish app to reside at eg. https://example.com/buckler +`db` specifies parameters for connecting to the PostgreSQL database. """ url_prefix = '/buckler' + +db = { + 'database': 'buckler', + 'user': 'buckler', + 'password': 'password', + 'host': 'localhost', + 'port': 5432, +} diff --git a/templates/login.html b/templates/login.html index 2a25b78..d9d5ef9 100644 --- a/templates/login.html +++ b/templates/login.html @@ -5,13 +5,12 @@