fourth commit
This commit is contained in:
parent
04156e39b3
commit
6acc767923
174
buckler.py
174
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():
|
||||
|
|
34
buckler.sql
34
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)
|
||||
);
|
||||
|
|
12
config.py.template
Normal file → Executable file
12
config.py.template
Normal file → Executable file
|
@ -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"""
|
||||
}
|
||||
|
|
56
mail.py
Normal file
56
mail.py
Normal 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)
|
|
@ -10,6 +10,9 @@
|
|||
<input id="username" name="username" type="text"><br>
|
||||
<label for="password">Password</label>
|
||||
<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">
|
||||
</form>
|
||||
</body>
|
||||
|
|
|
@ -5,15 +5,36 @@
|
|||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<input id="password" name="password" type="password" minlength="8" maxlength="10240"><br>
|
||||
<label for="password_verify">Verify Password</label>
|
||||
<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">
|
||||
</form>
|
||||
</body>
|
||||
|
|
9
templates/register_result.html
Normal file
9
templates/register_result.html
Normal 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
33
validation.py
Normal 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
|
Loading…
Reference in New Issue
Block a user