7
\$\begingroup\$

As a project to better my skills in Python, I wrote a password manager. It's a website that has user login/logout functionality, the ability to add websites and passwords, and displays the websites/passwords when the user logs in. Since this is my first project integrating Python with Flask, I would like feedback concerning security for the most part. I don't work with websites often, so the measures I've taken to secure this website might not be good enough.

File Structure

Password_Manager
| __pycache__
| creds
  | creds.txt
  | usernames.txt
| data
  | ...generated folders and saved websites/passwords...
| templates
  | data.html
  | index.html
  | login.html
  | signup.html
| config.py
| encryption.py
| server.py
| utils.py

server.py

"""

Written by: Ben Antonellis
Github: https://github.com/Linnydude3347

Sever Module for starting and stopping the server, and for dealing with
user signin, signout, adding websites to password list, and other server
functions
"""

import os
from functools import wraps

from flask import Flask, render_template, request, redirect, url_for, session, flash

from config import APP_KEY
from encryption import encrypt, decrypt
from utils import generate_uid, divide_data

APP = Flask(__name__)
APP.secret_key = APP_KEY

LOGGED_IN_USER = None

def login_required(func):
    """
    A decorator that requires the user to be logged in
    in order to access home and logout functionality
    """
    @wraps(func)
    def wrap(*args, **kwargs):
        if 'logged_in' in session:
            return func(*args, **kwargs)
        flash("You need to login first.")
        return redirect(url_for("login"))
    return wrap

@APP.route("/")
def main():
    """
    Function that returns the homepage for the
    password manager
    """
    return render_template("index.html")

@APP.route("/login", methods=["GET", "POST"])
def login():
    """
    Function for logging in the user
    """
    global LOGGED_IN_USER
    error = None
    if request.method == "POST":
        username = request.form['username']
        password = request.form['password']
        if not user_login(username, password):
            error = "Invalid username/password. Please try again."
        else:
            session['logged_in'] = True
            LOGGED_IN_USER = username
            flash("You were just logged in!")
            return redirect(url_for("main"))
    return render_template("login.html", error=error)

@APP.route("/logout")
@login_required
def logout():
    """
    Logs out the user
    """
    global LOGGED_IN_USER
    session.pop("logged_in", None)
    LOGGED_IN_USER = None
    flash("You were just logged out!")
    return redirect(url_for("main"))

@APP.route("/signup", methods=["GET", "POST"])
def signup():
    """
    Adds a user to the credentials file
    """
    error = None
    if request.method == "POST":
        with open("creds/creds.txt", "ab") as creds_file:
            username = request.form["username"]
            password = request.form["password"]
            if username == password:
                flash("Username cannot equal password!")
                return redirect(url_for("signup"))
            if not username or not password:
                flash("You must provide both a usernamd and password!")
                return redirect(url_for("signup"))
            if not duplicate_user(username):
                creds_file.write(encrypt(username))
                creds_file.write(b"\n")
                creds_file.write(encrypt(password))
                creds_file.write(b"\n")
                with open("creds/usernames.txt", "a") as usernames_file:
                    usernames_file.write(f"{username} {generate_uid()}\n")
            else:
                flash("There is already a user with that username. Try again.")
                return redirect(url_for("signup"))
        flash("You were signed up!")
        return redirect(url_for("login"))
    return render_template("signup.html", error=error)

@APP.route("/data", methods=["GET", "POST"])
@login_required
def data():
    """
    This returns a website that contains all the saved websites and passwords
    for those websites. It gets the UUID from the currently logged in user,
    gets the websites and passwords associated with that user, and passes them
    to the website to be displayed
    """
    if request.method == "POST":
        website = request.form['website']
        password = request.form['password']
        uuid = get_user_uuid()

        path = f"data/{uuid}/"
        filename = "data.txt"

        if not os.path.isdir(f"data/{uuid}"):
            os.mkdir(path)
            new_file = open(path + filename, "w")
            new_file.close()

        with open(path + filename, "wb") as data_file:
            data_file.write(encrypt(website))
            data_file.write(b"\n")
            data_file.write(encrypt(password))
            data_file.write(b"\n")
        return redirect(url_for("data"))
    uuid = get_user_uuid()
    sites, pswds = get_data(uuid)
    return render_template(
        "data.html",
        websites=sites,
        passwords=pswds
    )

def user_login(username: str, password: str) -> bool:
    """
    Returns a boolean if a username and password pair match
    and in the credentials file
    """
    with open("creds/creds.txt", "rb") as creds_file:
        try:
            data_ = creds_file.read().split(b"\n")
            credentials = list(divide_data(data_))
            for pair in credentials:
                if username == decrypt(pair[0]) and password == decrypt(pair[1]):
                    return True
            return False
        except Exception as _:
            return False

def duplicate_user(username: str) -> bool:
    """
    Returns a boolean based on if the passed username already
    exists in the credentials file
    """
    with open("creds/usernames.txt", "r") as usernames_file:
        data_ = usernames_file.read().split("\n")
        usernames = [user.split(" ")[0] for user in data_]
        return username in usernames

def get_user_uuid() -> int:
    """
    Gets the UUID for the currently logged in user
    """
    with open("creds/usernames.txt", "r") as usernames_file:
        data_ = usernames_file.read().split("\n")
        if data_:
            usernames = [user.split(" ")[0] for user in data_ if user != '']
            uuids = [uuid.split(" ")[1] for uuid in data_ if uuid != '']
            for username, uuid in zip(usernames, uuids):
                if username == LOGGED_IN_USER:
                    return uuid
    return None

def get_data(uuid) -> list:
    """
    Gathers all the websites and passwords for those
    websites and returns them
    """
    path = f"data/{uuid}/"
    filename = "data.txt"

    if not os.path.isdir(f"data/{uuid}"):
        os.mkdir(path)
        new_file = open(path + filename, "w")
        new_file.close()

    with open(path + filename, "rb") as data_file:
        data_ = data_file.read().split(b"\n")
        print(data_)
        websites = [decrypt(data_[0]) for site in data_ if site != b'']
        passwords = [decrypt(data_[1]) for password in data_ if password != b'']
    return websites, passwords

if __name__ == '__main__':
    APP.run(debug=True, host="0.0.0.0", port=80)

encryption.py

"""

Written by: Ben Antonellis
Github: https://github.com/Linnydude3347

Module for handing encryption and decryption of data
"""

from cryptography.fernet import Fernet

KEY = b'aQKbfHRvFtvN3QJwPWywwmcQ-0h_JwoOo3k-MjVUecw='
FERNET = Fernet(KEY)
CIPHER_SUITE = FERNET

def encrypt(data_: str) -> bytes:
    """
    Returns an encrypted version of the passed data
    """
    return CIPHER_SUITE.encrypt(bytes(data_, encoding="utf-8"))

def decrypt(data_: bytes) -> str:
    """
    Returns a decrypted version of the passed data
    """
    return CIPHER_SUITE.decrypt(data_).decode("utf-8")

utils.py

"""

Written by: Ben Antonellis
Github: https://github.com/Linnydude3347

Utility functions
"""

import random

def generate_uid() -> int:
    """
    Returns a random number between 10,000,000 and 99,999,999
    """
    return random.randint(10000000, 99999999)

def divide_data(data_: list) -> None:
    """
    Returns a 2D list of lists that contain a
    username and password, encrypted
    """
    for i in range(0, len(data_), 2):
        yield data_[i:i + 2]

config.py

"""

Written by: Ben Antonellis
Github: https://github.com/Linnydude3347

Configuration file for containing the Flask secret_key
"""

APP_KEY = "w456789oKNbvCDE456789oKNBVfr56YHGvGT6tf"
\$\endgroup\$
3
  • 1
    \$\begingroup\$ Now that you've posted your private keys all over the internet, you should change them right away. And make sure they stay private next time. \$\endgroup\$ Sep 10, 2019 at 17:47
  • \$\begingroup\$ @MathiasEttinger I appreciate the concern, but the Flask Secret Key is basically a face slam on my computer, and the encryption key is a randomly generated flask key that I copy pasted. I realize in production these keys are never to be revealed. \$\endgroup\$
    – Linny
    Sep 10, 2019 at 18:04
  • 4
    \$\begingroup\$ Perhaps consider using SSL/TLS since you're dealing with, uh, sensitive data, the port 80 there is a bit worrying without knowing more about the setup. \$\endgroup\$
    – ferada
    Sep 10, 2019 at 22:26

1 Answer 1

8
\$\begingroup\$

Disclaimer: I haven't done authentication in Flask before.

Tl;dr: Don't do authentication yourself, it's VERY hard to do right (and using a global ain't right); also, separate the user's authentication from their key store: the former you should ONLY be able to verify, not decode, and the latter should be inaccessible to your server without some secret information from the user.

Your code example is fairly long and I'm not terribly experienced with Flask, so I won't provide a robust judgement of the whole thing, though I will give you the compliment that your code was fairly easy to read and follow, with excellent docstrings, good modularization, and following general good practices... except that you use global variables, which is the death knell for this entire server. The user's identity should be pulled from the session, not stored in a global variable. As it is, only one user can be logged in a time, and if I log in first, then you log in second, then I can potentially see all of your passwords.

Here are some code-related suggestions, before I dive into how a password manager, as an entire application, should probably function:

  1. Use standard packages for standard tasks, particularly authentication.

  2. Avoid globals. The only exception, maybe, is you can have a config.py with non-secret constants. Otherwise, these are more likely to get you into trouble when your server starts running in multiple threads or processes, and state management becomes an issue.

  3. Don't be afraid to store things in a database. DB's are essential to just about every web app, and are very secure and very very fast. Getting familiar with using them is a basic necessity as you move forward with building bigger web apps.

  4. Load secure keys from files or environment variables, don't hard-code them. If you use files, they should be explicitly excluded from source control, so that someone with access to your source code won't also have access to your data.

  5. The fact that your "encrypt" and "decrypt" functions don't take keys as arguments should be indicative that you're doing something wrong. If the external code doesn't need to know anything secret about a data blob in order to access it, then why is it encrypted at all? If all you care about is encryption at-rest on-disk, there are better ways to do that (encrypted file systems, secure databases, password-protected zip files, etc.) where you don't need to manage, and thus perhaps introduce vulnerabilities into, the encryption/decryption process yourself.

As a philosophical suggestion, a robust password manager should probably 1) authenticate the user using standard best practices, then 2) provide an opaque blob to the client, who can then decrypt the data locally using some secret key of their own (maybe the same password they used to log in to the server). Ideally, at no point should the server be capable of accessing the user's secrets, and thus be capable of leaking them to internal or external threats.

For #1, you need to look into using a standard Flask authentication package, which will probably leverage a database of some sort. This database should be protected using usual means (password-protected, and only be accessible from the web server), and it should only store password hashes, no cleartext passwords. If you find a good authentication package, it'll do this for you. I know Django comes with robust authentication built-in (including session management, password hashing, etc.), so I'm sure something comparable exists for Flask. Do not build stuff like this from scratch, it's a big hassle/waste of time, makes your code messy, and will result in your application being riddled with security vulnerabilities.

For #2, this becomes tricky to do in a static website (hence why the most secure password managers usually involve a client-side browser plugin or other OS-installed component). It is possible using a little javascript embedded in the HTML page you return to the user, with some degree of security. You could pass the encrypted password store (binary data can be sent/stored in JSON using base64 encoding), then have the user re-enter their password client-side and have the javascript locally decrypt the blob and present its contents somehow. To update the data, just do so in-browser (don't send anything in cleartext to the server) and have the javascript re-package the blob, encrypt it, and send that encrypted blob back to the server. So long as the encryption library you use (and there are plenty of options) is implemented correctly, the password blob will be as safe as the algorithm and keysize you use (e.g., AES 256). This way, there's absolutely no way the server could reveal one user's secrets to another, since the server is never given the ability to decrypt any user's secrets, only authenticate them and given them their encrypted data blob.

Even if you're just building this as a toy to learn Python/Flask and don't actually care about making a decent password manager, you should still encrypt the user's data blob using some piece of information unique to that user that's stored somewhere secure (e.g. in the database). E.g., when they POST their password, use it to authenticate them first, then if that succeeds, use it to decrypt some random gibberish that you've stored in the User table of your database which is their unique key to their password store, and store that key in their session data (either in a cookie or in a session table in the database). When their session ends, the server becomes incapable of accessing the user's password store again and everything returns to being secure.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.