fourth commit

This commit is contained in:
iou1name 2019-09-19 20:49:40 -04:00
parent 04156e39b3
commit 6acc767923
8 changed files with 307 additions and 37 deletions

View File

@ -2,6 +2,7 @@
""" """
A security shield for protecting a number of small web applications. A security shield for protecting a number of small web applications.
""" """
import json
import secrets import secrets
import functools import functools
from datetime import datetime from datetime import datetime
@ -14,7 +15,9 @@ from passlib.hash import argon2
import asyncpg import asyncpg
import uvloop import uvloop
import mail
import config import config
import validation
uvloop.install() uvloop.install()
routes = web.RouteTableDef() routes = web.RouteTableDef()
@ -28,18 +31,19 @@ def auth_required(func):
login_url = request.app.router['login'].url_for() login_url = request.app.router['login'].url_for()
sid = request.cookies.get('session') sid = request.cookies.get('session')
try: try:
userid = int(request.cookies.get('userid')) user_id = int(request.cookies.get('userid', '0'))
except (ValueError, TypeError): except (ValueError, TypeError):
userid = None user_id = None
if not sid or not userid: if not sid or not user_id:
raise web.HTTPFound(location=login_url) raise web.HTTPFound(location=login_url)
async with request.app['pool'].acquire() as conn: async with request.app['pool'].acquire() as conn:
sessions = await conn.fetch( sessions = await conn.fetch(
"SELECT * FROM user_session " "SELECT * FROM user_session "
"WHERE user_id = $1 AND expiry > NOW() ", "WHERE user_id = $1 AND expires > NOW()",
userid) user_id)
session = [s for s in sessions if s.get('id') == sid] session = [s for s in sessions if s.get('id') == sid]
if session: if session:
resp = await func(request, *args, **kwargs)
session = session[0] session = session[0]
tz = session['last_used'].tzinfo tz = session['last_used'].tzinfo
delta = datetime.now(tz) - session['last_used'] delta = datetime.now(tz) - session['last_used']
@ -47,14 +51,12 @@ def auth_required(func):
async with request.app['pool'].acquire() as conn: async with request.app['pool'].acquire() as conn:
await conn.execute( await conn.execute(
"UPDATE user_session SET last_used = NOW(), " "UPDATE user_session SET last_used = NOW(), "
"expiry = NOW() + INTERVAL '30 DAYS'" "expires = NOW() + INTERVAL '30 DAYS' "
"WHERE user_id = $1", "WHERE user_id = $1",
userid) user_id)
resp = await func(request, *args, **kwargs)
if delta.seconds > 600:
resp.set_cookie( resp.set_cookie(
'userid', 'userid',
userid, user_id,
max_age=30*24*60*60, max_age=30*24*60*60,
secure=True, secure=True,
httponly=True) httponly=True)
@ -81,12 +83,13 @@ async def index(request):
@routes.post(config.url_prefix + '/login', name='login') @routes.post(config.url_prefix + '/login', name='login')
async def login(request): async def login(request):
"""Handle login.""" """Handle login."""
login_failed = False
if request.method == 'GET': if request.method == 'GET':
return render_template("login.html", request, locals()) return render_template("login.html", request, locals())
data = await request.post() form = await request.post()
username = data.get('username') username = form.get('username')
password = data.get('password') password = form.get('password')
async with request.app['pool'].acquire() as conn: async with request.app['pool'].acquire() as conn:
user_info = await conn.fetchrow( user_info = await conn.fetchrow(
@ -94,6 +97,7 @@ async def login(request):
username) username)
if not user_info: if not user_info:
login_failed = True
return render_template("login.html", request, locals()) return render_template("login.html", request, locals())
if argon2.verify(password, user_info['password_hash']): if argon2.verify(password, user_info['password_hash']):
@ -121,6 +125,7 @@ async def login(request):
httponly=True) httponly=True)
raise resp raise resp
else: else:
login_failed = True
return render_template("login.html", request, locals()) return render_template("login.html", request, locals())
@ -128,27 +133,130 @@ async def login(request):
@routes.post(config.url_prefix + '/register', name='register') @routes.post(config.url_prefix + '/register', name='register')
async def register(request): async def register(request):
"""Register new accounts.""" """Register new accounts."""
if request.method == 'POST': confirm_token = request.query.get('confirm')
data = await request.post() if confirm_token:
username = data.get('username') async with request.app['pool'].acquire() as conn:
email = data.get('email') confirm = await conn.fetchrow(
password = data.get('password') "SELECT * FROM email_confirmation WHERE token = $1",
password_verify = data.get('password_verify') confirm_token)
if confirm:
errors = {}
if password != password_verify:
errors[''] = ''
pw_hash = argon2.hash(password)
async with request.app['pool'].acquire() as conn: async with request.app['pool'].acquire() as conn:
await conn.execute( await conn.execute(
"INSERT INTO user_info (username, email, password_hash) " "UPDATE user_info SET active = TRUE WHERE id = $1",
"VALUES ($1, $2, $3)", confirm['user_id'])
username, email, pw_hash) await conn.execute(
login_url = request.app.router['login'].url_for() "DELETE FROM email_confirmation WHERE user_id = $1",
raise web.HTTPFound(location=login_url) 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()) 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(): async def init_app():
"""Initializes the application.""" """Initializes the application."""

View File

@ -2,7 +2,10 @@ CREATE TABLE IF NOT EXISTS user_info (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
email 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 ( CREATE TABLE IF NOT EXISTS user_session (
@ -10,6 +13,33 @@ CREATE TABLE IF NOT EXISTS user_session (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
ip_address INET NOT NULL, ip_address INET NOT NULL,
date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 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() 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)
);

12
config.py.template Normal file → Executable file
View File

@ -1,16 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Configuration settings for Buckler. Configuration settings for Buckler.
`server_domain` is the server's domain.
`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 eg. https://example.com/buckler
`db` specifies parameters for connecting to the PostgreSQL database. `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' url_prefix = '/buckler'
db = { db = {
'database': 'buckler', 'database': 'buckler',
'user': 'buckler', 'user': 'buckler',
'password': 'password', 'password': """password""",
'host': 'localhost', 'host': 'localhost',
'port': 5432, 'port': 5432,
} }
email = {
'host': 'mail.steelbea.me',
'port': 465,
'user': 'buckler@steelbea.me',
'password': """password"""
}

56
mail.py Normal file
View File

@ -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)

View File

@ -10,6 +10,9 @@
<input id="username" name="username" type="text"><br> <input id="username" name="username" type="text"><br>
<label for="password">Password</label> <label for="password">Password</label>
<input id="password" name="password" type="password"><br> <input id="password" name="password" type="password"><br>
{% if login_failed %}
<ul><li>Username and/or password incorrect</li></ul>
{% endif %}
<input type="submit" value="Login"> <input type="submit" value="Login">
</form> </form>
</body> </body>

View File

@ -5,15 +5,36 @@
</head> </head>
<body> <body>
<h1>Buckler Register</h1> <h1>Buckler Register</h1>
<form action="{{ request.app.router['register'].url_for() }}" method="post" enctype="application/x-www-form-urlencoded"> <form method="POST" enctype="application/x-www-form-urlencoded">
<label for="username">Username</label> <label for="username">Username</label>
<input id="username" name="username" type="text" minlength="3" maxlength="20"><br> <input id="username" name="username" type="text" minlength="3" maxlength="20"><br>
{% if errors['username'] %}
<ul>
{% for error in errors['username'] %}
<li class="error">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<label for="email">Email</label> <label for="email">Email</label>
<input id="email" name="email" type="email"><br> <input id="email" name="email" type="email"><br>
{% if errors['email'] %}
<ul>
{% for error in errors['email'] %}
<li class="error">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<label for="password">Password</label> <label for="password">Password</label>
<input id="password" name="password" type="password" minlength="8" maxlength="10240"><br> <input id="password" name="password" type="password" minlength="8" maxlength="10240"><br>
<label for="password_verify">Verify Password</label> <label for="password_verify">Verify Password</label>
<input id="password_verify" name="password_verify" type="password" minlength="8" maxlength="10240"><br> <input id="password_verify" name="password_verify" type="password" minlength="8" maxlength="10240"><br>
{% if errors['password'] %}
<ul>
{% for error in errors['password'] %}
<li class="error">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<input type="submit" value="Register"> <input type="submit" value="Register">
</form> </form>
</body> </body>

View File

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Buckler - Register</title>
</head>
<body>
{{ message }}
</body>
</html>

33
validation.py Normal file
View File

@ -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