From 6acc76792383ee221b042dcf2980be1ca5c84040 Mon Sep 17 00:00:00 2001 From: iou1name Date: Thu, 19 Sep 2019 20:49:40 -0400 Subject: [PATCH] fourth commit --- buckler.py | 174 ++++++++++++++++++++++++++------- buckler.sql | 34 ++++++- config.py.template | 12 ++- mail.py | 56 +++++++++++ templates/login.html | 3 + templates/register.html | 23 ++++- templates/register_result.html | 9 ++ validation.py | 33 +++++++ 8 files changed, 307 insertions(+), 37 deletions(-) mode change 100644 => 100755 config.py.template create mode 100644 mail.py create mode 100644 templates/register_result.html create mode 100644 validation.py diff --git a/buckler.py b/buckler.py index 0c0fee8..342abd3 100644 --- a/buckler.py +++ b/buckler.py @@ -2,6 +2,7 @@ """ A security shield for protecting a number of small web applications. """ +import json import secrets import functools from datetime import datetime @@ -14,7 +15,9 @@ from passlib.hash import argon2 import asyncpg import uvloop +import mail import config +import validation uvloop.install() routes = web.RouteTableDef() @@ -28,33 +31,32 @@ def auth_required(func): login_url = request.app.router['login'].url_for() sid = request.cookies.get('session') try: - userid = int(request.cookies.get('userid')) + user_id = int(request.cookies.get('userid', '0')) except (ValueError, TypeError): - userid = None - if not sid or not userid: + user_id = None + if not sid or not user_id: 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 AND expiry > NOW() ", - userid) + "WHERE user_id = $1 AND expires > NOW()", + user_id) session = [s for s in sessions if s.get('id') == sid] if session: + resp = await func(request, *args, **kwargs) session = session[0] tz = session['last_used'].tzinfo delta = datetime.now(tz) - session['last_used'] if delta.seconds > 600: async with request.app['pool'].acquire() as conn: await conn.execute( - "UPDATE user_session SET last_used = NOW()," - "expiry = NOW() + INTERVAL '30 DAYS'" + "UPDATE user_session SET last_used = NOW(), " + "expires = NOW() + INTERVAL '30 DAYS' " "WHERE user_id = $1", - userid) - resp = await func(request, *args, **kwargs) - if delta.seconds > 600: + user_id) resp.set_cookie( 'userid', - userid, + user_id, max_age=30*24*60*60, secure=True, httponly=True) @@ -81,12 +83,13 @@ async def index(request): @routes.post(config.url_prefix + '/login', name='login') async def login(request): """Handle login.""" + login_failed = False if request.method == 'GET': return render_template("login.html", request, locals()) - data = await request.post() - username = data.get('username') - password = data.get('password') + 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( @@ -94,6 +97,7 @@ async def login(request): username) if not user_info: + login_failed = True return render_template("login.html", request, locals()) if argon2.verify(password, user_info['password_hash']): @@ -121,6 +125,7 @@ async def login(request): httponly=True) raise resp else: + login_failed = True return render_template("login.html", request, locals()) @@ -128,26 +133,129 @@ async def login(request): @routes.post(config.url_prefix + '/register', name='register') async def register(request): """Register new accounts.""" - if request.method == 'POST': - data = await request.post() - username = data.get('username') - email = data.get('email') - password = data.get('password') - password_verify = data.get('password_verify') - - errors = {} - if password != password_verify: - errors[''] = '' - - pw_hash = argon2.hash(password) + confirm_token = request.query.get('confirm') + if confirm_token: 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) - return render_template("register.html", request, locals()) + 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 validation.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 mail.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(config.url_prefix + '/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']): + sessions = await conn.fetch( + "SELECT * FROM user_session " + "WHERE user_id = $1 AND expires > NOW()", + user_id) + session = [s for s in sessions if s.get('id') == user_sid] + if session: + session = session[0] + data = await conn.fetchrow( + "SELECT user_info.username, app_user.session_data " + "FROM user_info, app_user " + "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", + user_id) + await conn.close() + + data_meta = dict(data) + data_meta.update( + {'last_used': session['last_used'].isoformat()}) + data = { + 'meta': data_meta, + 'session_data': json.loads(data_meta.pop('session_data'))} + + print(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(config.url_prefix + '/set_session', name='set_session') +async def set_session(request): + """Allows an application to set a user app session.""" + pass async def init_app(): diff --git a/buckler.sql b/buckler.sql index b9e9bbf..0cfc576 100644 --- a/buckler.sql +++ b/buckler.sql @@ -2,7 +2,10 @@ CREATE TABLE IF NOT EXISTS user_info ( id SERIAL PRIMARY KEY, username TEXT NOT NULL, email TEXT NOT NULL, - password_hash TEXT + password_hash TEXT, + date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + active BOOLEAN NOT NULL DEFAULT FALSE, + admin BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE IF NOT EXISTS user_session ( @@ -10,6 +13,33 @@ CREATE TABLE IF NOT EXISTS user_session ( id TEXT PRIMARY KEY, ip_address INET NOT NULL, date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - expiry TIMESTAMP WITH TIME ZONE DEFAULT NOW() + INTERVAL '30 DAYS', + expires TIMESTAMP WITH TIME ZONE DEFAULT NOW() + INTERVAL '30 DAYS', last_used TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); + +CREATE TABLE IF NOT EXISTS invite ( + email TEXT PRIMARY KEY, + token TEXT NOT NULL, + date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires TIMESTAMP WITH TIME ZONE DEFAULT NOW() + INTERVAL '24 HOURS' +); + +CREATE TABLE IF NOT EXISTS email_confirmation ( + user_id INTEGER PRIMARY KEY references user_info(id) ON DELETE CASCADE, + token TEXT NOT NULL, + date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_info ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + key_hash TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS app_user ( + user_id INTEGER references user_info(id) ON DELETE CASCADE, + app_id INTEGER references app_info(id) ON DELETE CASCADE, + session_data JSON, + PRIMARY KEY (user_id, app_id) +); diff --git a/config.py.template b/config.py.template old mode 100644 new mode 100755 index 406b78a..20ad6c1 --- a/config.py.template +++ b/config.py.template @@ -1,16 +1,26 @@ #!/usr/bin/env python3 """ Configuration settings for Buckler. +`server_domain` is the server's domain. `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. +`email` specifies parameters for connecting to the SMTP server. """ +server_domain = 'https://steelbea.me' url_prefix = '/buckler' db = { 'database': 'buckler', 'user': 'buckler', - 'password': 'password', + 'password': """password""", 'host': 'localhost', 'port': 5432, } + +email = { + 'host': 'mail.steelbea.me', + 'port': 465, + 'user': 'buckler@steelbea.me', + 'password': """password""" +} diff --git a/mail.py b/mail.py new file mode 100644 index 0000000..77f8c8e --- /dev/null +++ b/mail.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Tools for sending emails. +""" +import email.mime.text +import smtplib +import secrets + +import config + +def send_mail(to_addr, subject, body): + """ + Sends an email. + """ + msg = email.mime.text.MIMEText(body, 'plain', 'utf8') + msg['From'] = config.email['user'] + msg['To'] = to_addr + msg['Subject'] = subject + + with smtplib.SMTP_SSL(config.email['host'],config.email['port']) as server: + server.login(config.email['user'], config.email['password']) + server.send_message(msg) + + +async def send_invite(request, to_addr): + """ + Sends an invitation. + """ + token = secrets.token_urlsafe(32) + async with request.app['pool'].acquire() as conn: + await conn.execute( + "INSERT INTO invite (email, token) " + "VALUES ($1, $2)", + to_addr, token) + d = {'invite': token} + invite_url = request.app.router['register'].url_for().with_query(d) + invite_url = config.server_domain + config.url_prefix + invite_url + body = "Buckle up.\n" + invite_url + send_mail(to_addr, "Buckler Invite", body) + + +async def send_confirmation(request, user_id, to_addr): + """ + Sends an email confirmation. + """ + token = secrets.token_urlsafe(32) + async with request.app['pool'].acquire() as conn: + await conn.execute( + "INSERT INTO email_confirmation (user_id, token) " + "VALUES ($1, $2)", + user_id, token) + d = {'confirm': token} + confirm_url = request.app.router['register'].url_for().with_query(d) + confirm_url = config.server_domain + str(confirm_url) + body = "Buckle up.\n" + confirm_url + send_mail(to_addr, "Buckler Invite", body) diff --git a/templates/login.html b/templates/login.html index d9d5ef9..98ca177 100644 --- a/templates/login.html +++ b/templates/login.html @@ -10,6 +10,9 @@

+ {% if login_failed %} + + {% endif %} diff --git a/templates/register.html b/templates/register.html index bb3c7dd..a89f52d 100644 --- a/templates/register.html +++ b/templates/register.html @@ -5,15 +5,36 @@

Buckler Register

-
+
+ {% if errors['username'] %} + + {% endif %}
+ {% if errors['email'] %} + + {% endif %}

+ {% if errors['password'] %} + + {% endif %}
diff --git a/templates/register_result.html b/templates/register_result.html new file mode 100644 index 0000000..25ecc59 --- /dev/null +++ b/templates/register_result.html @@ -0,0 +1,9 @@ + + + + Buckler - Register + + + {{ message }} + + diff --git a/validation.py b/validation.py new file mode 100644 index 0000000..75d2e7c --- /dev/null +++ b/validation.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +""" +Functions for validating forms. +""" + +async def validate_register(request, form): + """Validate data from the registration form.""" + username = form.get('username') + email = form.get('email') + password = form.get('password') + password_verify = form.get('password_verify') + + async with request.app['pool'].acquire() as conn: + users = await conn.fetch( + "SELECT username, email FROM user_info " + "WHERE username = $1 OR email = $2", + username, email) + + errors = {'password': [], 'username': [], 'email': []} + if password != password_verify: + errors['password'].append("Passwords do not match.") + if len(password) < 8 or len(password) > 10240: + errors['password'].append( + "Password must be between 8 and 10240 characters long.") + if len(username) < 3 or len(username) > 20: + errors['username'].append( + "Username must be between 3 and 20 characters long.") + if username in [user['username'] for user in users]: + errors['username'].append("Username already in use.") + if email in [user['email'] for user in users]: + errors['email'].append("Email already in use.") + + return errors