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 %}
+ - Username and/or password incorrect
+ {% endif %}
Buckler Register
-
+ {{ message }}
+
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 @@
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 @@
+
+
+