Browse Source

Initial commit: added login server setup

See https://github.com/makuto/tornado-authenticated-template
master
Macoy Madson 11 months ago
parent
commit
b093729d3f
8 changed files with 720 additions and 0 deletions
  1. +4
    -0
      .gitignore
  2. +227
    -0
      LazyBudgetServer.py
  3. +124
    -0
      PasswordManager.py
  4. +66
    -0
      ReadMe.org
  5. +29
    -0
      templates/Login.html
  6. +38
    -0
      templates/LoginCreate.html
  7. +30
    -0
      webInterfaceNoAuth/LoginCreate.js
  8. +202
    -0
      webInterfaceNoAuth/index.css

+ 4
- 0
.gitignore View File

@@ -127,3 +127,7 @@ dmypy.json

# Pyre type checker
.pyre/

# Authentication secrets
certificates/
accounts.json

+ 227
- 0
LazyBudgetServer.py View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3

import tornado.ioloop
import tornado.web
import tornado.websocket
import tornado.httpclient
import tornado.gen

import os
import random

# Require a username and password in order to use the web interface. See ReadMe.org for details.
#enable_authentication = False
enable_authentication = True

# Allow anyone to create an account even after the first account has been created
# This is a security vulnerability that should be considered before being enabled.
# Do you want anyone to be able to use the service provided by your server?
enable_subsequent_account_creation = False
# enable_subsequent_account_creation = True

# If "next" isn't specified from login, redirect here after login instead
landingPage = "/"

if enable_authentication:
import PasswordManager

# List of valid user ids (used to compare user cookie)
authenticated_users = []

# See https://github.com/tornadoweb/tornado/blob/stable/demos/blog/blog.py
# https://www.tornadoweb.org/en/stable/guide/security.html

def login_get_current_user(handler):
if enable_authentication:
cookie = handler.get_secure_cookie("user")
if cookie in authenticated_users:
return cookie
else:
print("Bad/expired cookie received")
return None
else:
return "authentication_disabled"

class AuthHandler(tornado.web.RequestHandler):
def get_current_user(self):
return login_get_current_user(self)

class LoginNewAccountHandler(AuthHandler):
def get(self):
if not enable_subsequent_account_creation:
self.redirect("/login")
else:
# New password setup
self.render("templates/LoginCreate.html",
next=self.get_argument("next", landingPage),
xsrf_form_html=self.xsrf_form_html())
class LoginHandler(AuthHandler):
def get(self):
if not enable_authentication:
self.redirect("/")
else:
if PasswordManager.havePasswordsBeenSet():
self.render("templates/Login.html",
next=self.get_argument("next", landingPage),
xsrf_form_html=self.xsrf_form_html(),
create_accounts_html = ("" if not enable_subsequent_account_creation
else '<p>Go <a href="/createNewAccount">here</a> to create an account.</p>'))
else:
# New password setup
self.render("templates/LoginCreate.html",
next=self.get_argument("next", landingPage),
xsrf_form_html=self.xsrf_form_html())

def post(self):
global authenticated_users
# Test password
print("Attempting to authorize user {}...".format(self.get_argument("name")))
if (enable_authentication
and PasswordManager.verify(self.get_argument("name"), self.get_argument("password"))):
# Generate new authenticated user session
randomGenerator = random.SystemRandom()
cookieSecret = str(randomGenerator.getrandbits(128))
authenticated_user = self.get_argument("name") + "_" + cookieSecret
authenticated_user = authenticated_user.encode()
authenticated_users.append(authenticated_user)
# Set the cookie on the user's side
self.set_secure_cookie("user", authenticated_user)
print("Authenticated user {}".format(self.get_argument("name")))
# Let them in
self.redirect(self.get_argument("next", landingPage))
else:
print("Refused user {} (password doesn't match any in database)"
.format(self.get_argument("name")))
self.redirect("/login")

class LogoutHandler(AuthHandler):
@tornado.web.authenticated
def get(self):
global authenticated_users
if enable_authentication:
print("User {} logging out".format(self.current_user))
if self.current_user in authenticated_users:
authenticated_users.remove(self.current_user)
self.redirect("/login")
else:
self.redirect("/")

class CreateAccountHandler(AuthHandler):
def get(self):
pass

def post(self):
if not enable_authentication:
self.redirect("/")
else:
print("Attempting to set password")
if not enable_subsequent_account_creation and PasswordManager.havePasswordsBeenSet():
print("Rejected: Accounts cannot be created via the server if one already has been made.")
elif self.get_argument("password") != self.get_argument("password_verify"):
print("Rejected: password doesn't match verify field!")
else:
result = PasswordManager.createAccount(self.get_argument("name"),
self.get_argument("password"))
if not result[0]:
print("Failed: {}".format(result[1]))
print("Success: {}".format(result[1]))

self.redirect("/login")

class AuthedStaticHandler(tornado.web.StaticFileHandler):
def get_current_user(self):
return login_get_current_user(self)
@tornado.web.authenticated
def prepare(self):
pass

class HomeHandler(AuthHandler):
@tornado.web.authenticated
def get(self):
# Replace with your desired behavior, e.g.
# self.render('webInterface/index.html')
self.write('You are logged in!')

class ExampleWebSocket(tornado.websocket.WebSocketHandler):
connections = set()

def open(self):
global userSessionData
currentUser = login_get_current_user(self)
if not currentUser:
# Failed authorization
return None
self.connections.add(self)
def on_message(self, message):
currentUser = login_get_current_user(self)
if not currentUser:
# Failed authorization
return None
def on_close(self):
self.connections.remove(self)

#
# Startup
#

def make_app():
# Each time the server starts up, invalidate all cookies
randomGenerator = random.SystemRandom()
cookieSecret = str(randomGenerator.getrandbits(128))
return tornado.web.Application([
(r'/', HomeHandler),

# Login
(r'/login', LoginHandler),
(r'/logout', LogoutHandler),
(r'/createNewAccount', LoginNewAccountHandler),
(r'/finishCreateAccount', CreateAccountHandler),
# (r'/ExampleWebSocket', ExampleWebSocket),

# Static files
# (r'/webInterface/(.*)', AuthedStaticHandler, {'path' : 'webInterface'}),

# Files served regardless of whether the user is authenticated. Only login page resources
# should be in this folder, because anyone can see them
(r'/webInterfaceNoAuth/(.*)', tornado.web.StaticFileHandler, {'path' : 'webInterfaceNoAuth'}),
],
xsrf_cookies=True,
cookie_secret=cookieSecret,
login_url="/login")

if __name__ == '__main__':
port = 8888
print('\nStarting Authenticated Server on port {}...'.format(port))
app = make_app()

# Generating a self-signing certificate:
# openssl req -x509 -nodes -days 365 -newkey rsa:1024 -keyout certificates/server_jupyter_based.crt.key -out certificates/server_jupyter_based.crt.pem
# (from https://jupyter-notebook.readthedocs.io/en/latest/public_server.html)
# I then had to tell Firefox to trust this certificate even though it is self-signing (because
# I want a free certificate for this non-serious project)
useSSL = True
if useSSL:
if os.path.exists("certificates/server.crt.pem"):
app.listen(port, ssl_options={"certfile":"certificates/server.crt.pem",
"keyfile":"certificates/server.crt.key"})
else:
print('\n\tERROR: Certificates non-existent! Run ./Generate_Certificates.sh to create them')
else:
# Show the warning only if SSL is not enabled
print('\n\tWARNING: Do NOT run this server on the internet (e.g. port-forwarded)'
' nor when\n\t connected to an insecure LAN! It is not protected against malicious use.\n')
app.listen(port)
ioLoop = tornado.ioloop.IOLoop.current()
ioLoop.start()

+ 124
- 0
PasswordManager.py View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python
#
# Password manager: handles hashing and comparing for passwords used.
# I'm no expert so use and trust at your own risk!
#
from passlib.context import CryptContext

# It's not strictly necessary to import these, but I do it here for PyInstaller
# (see https://github.com/pyinstaller/pyinstaller/issues/649)
import argon2
import cffi
import configparser
import passlib.handlers
import passlib.handlers.argon2
import passlib.handlers.sha2_crypt
import passlib.handlers.bcrypt

import sys
import os
import json
import getpass

# Even if this file gets compromised, it'll still be hard to use for anything due to salting
accountsFilename = "accounts.json"

password_context = CryptContext(
# Replace this list with the hash(es) you wish to support.
# this example sets pbkdf2_sha256 as the default,
# with additional support for reading legacy des_crypt hashes.
schemes=["argon2", "sha512_crypt", "bcrypt"],

# Automatically mark all but first hasher in list as deprecated.
# (this will be the default in Passlib 2.0)
deprecated="auto",

# Optionally, set the number of rounds that should be used.
# Appropriate values may vary for different schemes,
# and the amount of time you wish it to take.
# Leaving this alone is usually safe, and will use passlib's defaults.
## pbkdf2_sha256__rounds = 29000,
)

accounts = {}

# Note that this class needs to be kept simple, otherwise JSON serialization will break
class Account:
def __init__(self, username, passwordHashed):
self.username = username
self.passwordHashed = passwordHashed

def havePasswordsBeenSet():
return os.path.exists(accountsFilename)

def loadAccounts():
global accounts

if not havePasswordsBeenSet():
return

passwordsFile = open(accountsFilename, "r")
accountsJson = passwordsFile.readlines()
passwordsFile.close()

for line in accountsJson:
accountParsed = json.loads(line)
account = Account(accountParsed["username"], accountParsed["passwordHashed"])
accounts[account.username] = account

def verify(username, password):
if not accounts:
loadAccounts()
if not accounts:
raise Exception("Tried to verify an account, but {} has no accounts or does not exist!"
.format(accountsFilename))
if username not in accounts:
# Username not found
return False
else:
if password_context.verify(password, accounts[username].passwordHashed):
return True
return False

def createAccount(username, password):
if not accounts:
loadAccounts()
if username in accounts:
return (False, "Failed to create account: Username not unique")

passwordHashed = password_context.hash(password)
accountPair = Account(username, passwordHashed)
accountsOutFile = open(accountsFilename, "a")
json.dump(accountPair.__dict__, accountsOutFile)
accountsOutFile.write("\n")
accountsOutFile.close()
loadAccounts()
return (True, "Account created successfully")

if __name__ == "__main__":
print("PasswordManager: Create a new account\n")

username = input("\tEnter username: ")
password = None
while True:
password = getpass.getpass("\tEnter password: ")
verifyPassword = getpass.getpass("\tVerify password: ")
if not password:
print("Please enter a password")
elif password != verifyPassword:
print("Passwords do not match! Try again")
else:
break

result = createAccount(username, password)
print("[Create Account] {}".format(result[1]))

if result[0]:
# To verify
print("Account created! Please test.")
username = input("\tEnter username: ")
password = getpass.getpass("\tEnter password: ")

print("Authentication successful: {}".format(verify(username, password)))

+ 66
- 0
ReadMe.org View File

@@ -0,0 +1,66 @@
#+TITLE: Lazy Budget
This is a web server and web interface for doing quick budgeting. Its goals are as follows:
- *Lazy:* It should be easy to categorize and input new transactions
- *Private:* No data should leave the computer
- *Informative:* Using this app gives insight into spending habits and problems
* Setup
** Directions

*** 1. Clone this repository

#+BEGIN_SRC sh
git clone https://github.com/makuto/tornado-authenticated-template
#+END_SRC

*** 2. Install python dependencies

The following dependencies are required:

#+BEGIN_SRC sh
pip install tornado passlib bcrypt argon2_cffi
#+END_SRC

You'll want to use Python 3, which for your environment may require you to specify ~pip3~ instead of just ~pip~.

*** 3. Generate SSL keys

#+BEGIN_SRC sh
cd tornado-authenticated-template/
./Generate_Certificates.sh
#+END_SRC

This step is only required if you want to use SSL, which ensures you have an encrypted connection to the server.

*** 4. Run the server

#+BEGIN_SRC sh
python3 AuthenticatedServer.py
#+END_SRC

*** 5. Test it

Open [[https://localhost:8888][localhost:8888]] in any web browser

If your web browser complains about the certificate, you may have to click ~Advanced~ and add the certificate as trustworthy, because you've signed the certificate and trust yourself :). If you want to get rid of this, you'll need to get a signing authority like ~LetsEncrypt~ to generate your certificate.

** Creating accounts
*** Create your account(s)
**** Creating accounts from the web interface
When you first run the server, the Create Account interface will automatically show up when visiting [[https://localhost:8888][localhost:8888]].

Note that this will be the only account that can be created through the web interface. If you want to let others create accounts, open AuthenticatedServer.py and set ~enable_subsequent_account_creation = True~. Then, anyone may visit [[https://localhost:8888/createNewAccount][localhost:8888/createNewAccount]] to create a new account.
**** Creating accounts from the command line

You can use ~PasswordManager.py~ to edit file ~accounts.json~ with hashed (and salted) passwords:

#+BEGIN_SRC sh
python3 PasswordManager.py
#+END_SRC

If you want to reset all accounts, simply delete ~accounts.json~.

*** Restart your server

You should now see a Login page before being able to access any content.

Note that all login cookies will be invalidated each time you restart the server.

+ 29
- 0
templates/Login.html View File

@@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!-- For mobile: set scale to native -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login</title>
<link rel="stylesheet" type="text/css" href="webInterfaceNoAuth/index.css">
</head>
<body>
<h1>Login</h1>
{% raw create_accounts_html %}
<form action="/login" method="post">
<label>Name</label><input type="text" name="name" autofocus><br />
<!-- For default username -->
<!-- <input type="hidden" name="name" value="DefaultUser"> -->
<label>Password</label><input type="password" name="password" autofocus>
<input type="hidden" name="next" value="{{ next }}"><br />
{% raw xsrf_form_html %}
<br /><input type="submit" value="Sign in">
</form>

<br />
</body>
</html>

+ 38
- 0
templates/LoginCreate.html View File

@@ -0,0 +1,38 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!-- For mobile: set scale to native -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login</title>
<link rel="stylesheet" type="text/css" href="webInterfaceNoAuth/index.css">
</head>
<body>
<h1>Create Account</h1>
<p>Please choose a name and password.</p>
<form action="/finishCreateAccount" method="post">
<label>Name</label><input type="text" name="name" autofocus><br />
<label>Password</label><input type="password" name="password" id="password" autofocus>
<label>Verify Password</label><input type="password" name="password_verify" id="verifyPassword">
<input type="hidden" name="next" value="{{ next }}"><br />
{% raw xsrf_form_html %}
<br />
<span style="width: 100%">
<input type="submit" value="Set password" id="setPasswordSubmit">
<p id="passwordStatus"></p>
</span>
</form>


<script type="text/javascript" src="webInterfaceNoAuth/LoginCreate.js"></script>
</body>
</html>

+ 30
- 0
webInterfaceNoAuth/LoginCreate.js View File

@@ -0,0 +1,30 @@

var passwordField = document.getElementById('password');
var verifyPasswordField = document.getElementById('verifyPassword');
var statusField = document.getElementById('passwordStatus');
var setPasswordSubmitButton = document.getElementById('setPasswordSubmit');

var verifyPasswords =
function() {
if (!passwordField.value)
{
/* statusField.style.display = 'block'; */
statusField.innerText = 'Password must not be empty';
setPasswordSubmitButton.disabled = true;
}
else if (verifyPasswordField.value != passwordField.value)
{
/* statusField.style.display = 'block'; */
statusField.innerText = 'Passwords do not match';
setPasswordSubmitButton.disabled = true;
}
else
{
/* statusField.style.display = 'none'; */
statusField.innerText = '';
setPasswordSubmitButton.disabled = false;
}
}

verifyPasswordField.onkeyup = verifyPasswords;
passwordField.onkeyup = verifyPasswords;

+ 202
- 0
webInterfaceNoAuth/index.css View File

@@ -0,0 +1,202 @@
footer {
margin-top: 40px;
font-size: small;
}

footer a {
margin-right: 10px;
}

nav a {
font-size: x-large;
}

a.small {
/* Lol */
font-size: large;
}

html {
margin: 15 15 15 15;
background-color: #1c2023;
}

body {
/* Center body */
margin: 0 auto;
padding: 30px;
/* Make sure lines don't get too long */
max-width: 700px;
}

p,
blockquote,
h1,
h2,
h3,
a,
li,
tr,
td,
th,
time,
label {
color: #c7ae95;
font-family: Arial;
line-height: 1.65;
}

table {
border-collapse: separate;
border-spacing: 20px 0;

/* table-layout: fixed; */
/* width: 100%; */
}

th {
text-align: left;
}

tr.odd {
background-color: #282e32;
}

/* unvisited link */
a:link {
/* color: red; */
text-decoration: none;
}

/* visited link */
a:visited {
color: #c7c795;
text-decoration: none;
}

/* mouse over link */
a:hover {
color: #aec795;
text-decoration: underline;
}

/* selected link */
a:active {
color: #95c7ae;
text-decoration: underline;
}

/* Regular body text */
p,
blockquote,
li,
tr,
td,
input,
textarea,
button {
color: #95aec7;
}

time,
.timeLabel {
/* color: #95aec7; */
color: #7c91a5;
font-style: italic;
}

/* Font sizes for consistently sized text */
p,
blockquote,
a,
li,
input {
font-size: medium;
}

label {
font-size: large;
color: #B08989;
}

img {
width: 100%;
height: auto;
}

::selection {
background-color: #c7ae95;
color: #000000;
}

textarea,
input {
background-color: #22282d;
font-family: Arial;
}

textarea,
input[type=text],
input[type=password],
input[type=number] {
border: 2px solid #7c91a5;
border-radius: 4px;
width: 100%;
padding: 12px 20px;
margin: 8px 0;
box-sizing: border-box;
}

textarea:focus,
input[type=text]:focus,
input[type=password]:focus,
input[type=number]:focus,
input[type=button]:focus,
input[type=button]:focus-within,
input[type=submit]:focus-within,
input[type=reset]:focus-within,
button:focus-within {
border: 3px solid #ffce9a;
}

input[type=button], input[type=submit], input[type=reset], button {
border: 3px solid #9c7575;
border-radius: 4px;
padding: 16px 32px;
text-decoration: none;
cursor: pointer;

background-color: #775050;
color: #ffffea;
font-family: Arial;
font-size: large;
}

input[type=button]:hover,
input[type=submit]:hover,
input[type=reset]:hover,
button:hover {
border: 3px solid #c7c795;
}

input[type=checkbox] {
transform: scale(1.3,1.3);
}

input[type=button]:disabled,
input[type=submit]:disabled,
input[type=reset]:disabled,
button:disabled {
border: 3px solid #c76464;
cursor:not-allowed;
background-color: #222222;
color: #aaaa9c;
}

.OutputScrollbox {
background-color: #22282d;
height: 200px;
overflow: auto;
padding: 10px;
margin-bottom: 10px;
}

Loading…
Cancel
Save