I have been working on a small project intended for helping beginner Python programmers getting an idea of what a real project could be like (as opposed to programming exercises and such).
This application can create rudimentary user accounts and allow login with password verification. It doesn't do anything beyond that. The user data is stored in a JSON file.
I've written it to be as simple to understand as possible (without commenting each line), and have attempted to keep it to one instruction per line, whenever possible, so please keep that in mind when reviewing, that is done on purpose, I know the code could be made shorter by combining multiple instructions.
I'm interested in all improvements, but in particular, improvements to make the code easier to understand.
Saved user records look like this in JSON file:
[
{
"username": "test1",
"password": "$2b$12$2ksuZeBJsvvEhoTWIdbSReGp753Tb1/cNvlVC2gmjOHRkDeoenl2K",
"created": "2018-07-07 00:16:28.293901",
"active": true
},
{
"username": "test2",
"password": "$2b$12$n80DfPOxo4gIu504o/Yn6.CZCd1aa6s7YDhaFVB7ZJFJpogqIjRc6",
"created": "2018-07-07 00:28:07.315781",
"active": true
}
...
]
application.py
This is the launching point, nothing to it, but included for completeness.
from user_interface import *
if __name__ == '__main__':
main_menu()
encryption.py
This is where I handle encrypting and checking passwords using bcrypt
.
import bcrypt
def encrypt_password(password: str) -> str:
"""
Encrypt a password with a randomly generated salt then a hash.
Cost rounds added to slow down the process in case of rainbow table/brute force attack.
:param password: The password to encrypt in clear text.
:return: The encrypted password as a unicode string.
"""
encoded_password = password.encode('utf8')
cost_rounds = 12
random_salt = bcrypt.gensalt(cost_rounds)
hashed_password = bcrypt.hashpw(encoded_password, random_salt).decode('utf8', 'strict')
return hashed_password
def check_password(password: str, password_hash: str) -> bool:
"""
Check a password against its encrypted hash for a match.
:param password: the password to check in clear text. (Unicode)
:param password_hash: The encrypted hash to check against. (Unicode)
:return: Whether the password and the hash match
"""
encoded_password = password.encode('utf8')
encoded_password_hash = password_hash.encode('utf8')
password_matches = bcrypt.checkpw(encoded_password, encoded_password_hash)
return password_matches
if __name__ == '__main__':
test_password = 'Password1'
hashed_test_password = encrypt_password(test_password)
print(f'hashed_password: {hashed_test_password}')
password_matches_hash = check_password(test_password, hashed_test_password)
print(f'password matches hash? {password_matches_hash}')
json_handling.py
This module handles file operations, specifically on JSON files.
import json
from pathlib import Path
from typing import List, Union
def create_file_if_not_exists(file_path: str) -> None:
"""
Checks if a file exists at the given path, and creates it if it doesn't.
:param file_path: the path of the file to check/create, which can be relative or absolute.
:return: None
"""
Path(file_path).touch()
def get_json_file_contents(file_path: str) -> Union[List, None]:
"""
Reads and return the contents of a JSON file.
:param file_path: The path where the JSON file is located.
:return: The contents of the file, or None if there if the file is empty or not found.
"""
try:
json_file = open(file_path)
except IOError:
return None
try:
file_contents = json.load(json_file)
except ValueError:
file_contents = None
json_file.close()
return file_contents
user_storage.py
This module is responsible for various tasks specifically related to handling creating and retrieving user data from the JSON file.
import datetime
from typing import Dict
from encryption import *
from json_handling import *
DEFAULT_FILE_PATH = 'data/users.json'
def prepare_new_user_data(username: str, password: str) -> Dict:
"""
Return user data ready for storage.
:param username: The username for this user.
:param password: The password for this user, in clear text.
:return: A Dict containing user data ready to store, including encrypted password.
"""
new_user = {
'username': username,
'password': encrypt_password(password),
'created': str(datetime.datetime.now()),
'active': True
}
return new_user
def check_if_user_already_exists(username: str, json_file_path: str=DEFAULT_FILE_PATH) -> bool:
"""
Queries a JSON file and returns whether it already exists.
:param username: The username to check for duplication.
:param json_file_path: The path where the JSON file is located.
:return: Whether the username already exists.
"""
all_users = get_json_file_contents(json_file_path)
if not all_users:
return False
for user in all_users:
if user['username'] == username:
return True
return False
def add_user(username: str, password: str, json_file_path: str=DEFAULT_FILE_PATH) -> None:
"""
Adds a user to a JSON file, unless it is a duplicate, in which case it raises a ValueError.
:param username: The username of the user to add.
:param password: The password of the user to add, in clear text.
:param json_file_path: The path where the JSON file to add the user to is located.
:return: None
"""
create_file_if_not_exists(json_file_path)
is_duplicate_user = check_if_user_already_exists(username, json_file_path)
if is_duplicate_user:
raise ValueError(f'Username "{username}" already exists.')
new_user = prepare_new_user_data(username, password)
all_users = get_json_file_contents(json_file_path)
if not all_users:
all_users = []
all_users.append(new_user)
with open(json_file_path, 'w') as users_file:
json.dump(all_users, users_file, indent=2)
def retrieve_user(username: str, json_filepath: str=DEFAULT_FILE_PATH) -> Union[Dict, None]:
"""
Returns a single user record from the target JSON file.
:param username: the username to search for.
:param json_filepath: The path where the JSON file to retrieve the user from is located.
:return: The user record as a Dict, or None if it is not found.
"""
all_users = get_json_file_contents(json_filepath)
for user in all_users:
if user['username'] == username:
return user
return None
def authenticate_username_and_password(username: str, password: str) -> bool:
"""
Verify that the provided username and password match what is stored in the user data,
for authentication purposes.
:param username: The user's username.
:param password: The user's password, in clear text.
:return: Whether the authentication was successful.
"""
user = retrieve_user(username)
password_hash = user['password']
if not user:
return False
if not check_password(password, password_hash):
return False
return True
if __name__ == '__main__':
test_username = 'test1'
test_password = 'Password1'
print(prepare_new_user_data(test_username, test_password))
test_file_path = 'data/test_database.json'
create_file_if_not_exists(test_file_path)
add_user(test_username, test_password, test_file_path)
print(get_json_file_contents(test_file_path))
user_interface.py
This module handles interaction between the application and the user, which is done right now through the console.
import getpass
import re
from user_storage import *
def main_menu() -> None:
"""
Displays the main menu of the application.
:return: None
"""
menu = '\n'.join([
'Select an option by entering its number and pressing Enter.',
'1. Create a user account',
'2. Log in to existing account',
'---'
])
print(menu)
valid_selections = [1, 2]
input_is_valid = False
selection = None
while not input_is_valid:
try:
selection = int(input('Selection: '))
if selection in valid_selections:
input_is_valid = True
else:
print('The number you entered is not a valid selection.')
except ValueError:
print('The value you entered is not a number.')
handle_main_menu_selection(selection)
def handle_main_menu_selection(selection: int) -> None:
"""
Calls the function related to the selection the user made.
:param selection: The user's selection.
:return: None
"""
if selection == 1:
create_new_user_menu()
elif selection == 2:
user_login_menu()
else:
raise ValueError(f'Selection {selection} is invalid.')
def create_new_user_menu() -> None:
"""
Displays the account creation menu, including asking the user for username and password.
:return: None
"""
menu = '\n'.join([
'---',
'Account creation',
'Username must...',
'\t- be at least 3 characters long',
'\t- contain only letters, numbers, and underscores',
'Password must...',
'\t- be at least 8 characters long',
'---'
])
print(menu)
user_added_successfully = False
username = ''
while not user_added_successfully:
try:
username = get_username_input()
password = get_password_input()
user_added_successfully = try_adding_user(username, password)
if not user_added_successfully:
print(f'Username "{username}" already exists.')
except ValueError as error:
print(str(error))
def try_adding_user(username: str, password: str) -> bool:
"""
Attempts to add a user to the user database file.
:param username: The username provided by the user.
:param password: The password provided to the user, in clear text.
:return: Whether the user was added successfully.
"""
try:
add_user(username, password)
return True
except ValueError:
return False
def user_login_menu() -> None:
menu = '\n'.join([
'---',
'User login',
'---'
])
print(menu)
login_successful = False
while not login_successful:
username = get_username_input()
password = get_password_input()
login_successful = authenticate_username_and_password(username, password)
if not login_successful:
print('Incorrect username or password.')
print('Login successful.')
def get_username_input() -> str:
"""
Request username input from the user.
:return: The username entered by the user.
"""
minimum_length = 3
username = input('Enter username: ')
if len(username) < minimum_length:
raise ValueError('Username must be at least 3 characters.')
# match upper & lower case letters, numbers, and underscores
pattern = re.compile('^([a-zA-Z0-9_]+)$')
if not pattern.match(username):
raise ValueError('Username must consist of only letters, numbers, and underscores.')
return username
def get_password_input() -> str:
"""
Request password input from the user.
:return: The password entered by the user.
"""
minimum_length = 8
password = getpass.getpass('Enter password: ')
if len(password) < minimum_length:
raise ValueError('Password must be at least 8 characters.')
return password
if __name__ == '__main__':
main_menu()