client side hashing, shared via JSON, password reset working

This commit is contained in:
Lucas Mathews
2024-05-29 19:06:02 +02:00
parent b70ba6ae2e
commit 6c7883657d
11 changed files with 290 additions and 107 deletions

105
api.yml
View File

@@ -29,24 +29,46 @@ paths:
summary: Log in to the system summary: Log in to the system
description: Log in to the system description: Log in to the system
operationId: manager.login operationId: manager.login
parameters: requestBody:
- name: client_id description: Login credentials
in: query required: true
description: client_id content:
required: true application/json:
schema: schema:
type: string type: object
- name: password properties:
in: query client_id:
description: Password type: string
required: true description: Client ID
schema: client_hash:
type: string type: string
description: Hashed password
required:
- client_id
- client_hash
responses: responses:
'200': '200':
description: Successful operation description: Successful operation
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
message:
type: string
'401': '401':
description: Invalid Client ID/password supplied description: Invalid Client ID/password supplied
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
message:
type: string
/Client/Logout: /Client/Logout:
post: post:
tags: tags:
@@ -78,39 +100,40 @@ paths:
summary: Change password summary: Change password
description: Change password description: Change password
operationId: manager.change_password operationId: manager.change_password
parameters: requestBody:
- name: client_id required: true
in: query content:
description: ID of client to change password application/json:
required: true schema:
schema: type: object
type: string properties:
- name: password client_id:
in: query type: string
description: New password description: ID of client to change password
required: true hash_old_password:
schema: type: string
type: string description: Old password as a hash
- name: new_password hash_new_password:
in: query type: string
description: New password description: New password as a hash
required: true otp_code:
schema: type: integer
type: string format: int32
- name: otp description: OTP to verify
in: query required:
description: OTP to verify - client_id
required: true - hash_old_password
schema: - hash_new_password
type: integer - otp_code
format: int32
responses: responses:
'200': '200':
description: Password changed successfully description: Password changed successfully
'400': '400':
description: Old password incorrect description: Validation error
'401':
description: Unauthorised
'404': '404':
description: client_id not found description: Client not found
/OTP/Generate: /OTP/Generate:
post: post:
tags: tags:

View File

@@ -12,6 +12,7 @@ from connection import get_transactions, format_balance, get_account, generate_o
description_entry = None description_entry = None
notes_entry = None notes_entry = None
otp_entry = None otp_entry = None
notes_text = None
################# #################
### Functions ### ### Functions ###
@@ -64,6 +65,8 @@ def display_account_info(account_id):
label_value = customtkinter.CTkLabel(info_frame, text=value, font=("Helvetica", 14)) label_value = customtkinter.CTkLabel(info_frame, text=value, font=("Helvetica", 14))
label_key.grid(row=0, column=i*2, sticky='w', padx=10) label_key.grid(row=0, column=i*2, sticky='w', padx=10)
label_value.grid(row=0, column=i*2+1, sticky='w', padx=10) label_value.grid(row=0, column=i*2+1, sticky='w', padx=10)
global notes_text
notes_text.configure(text=account.get('notes', '')) # Use config instead of configuration
def on_transaction_double_click(event): def on_transaction_double_click(event):
"""Handles double-click event on a transaction in the table.""" """Handles double-click event on a transaction in the table."""
@@ -157,7 +160,7 @@ def save_details():
root = customtkinter.CTk() root = customtkinter.CTk()
root.title(f"Transactions for: {account_description}") root.title(f"Transactions for: {account_description}")
root.iconbitmap("application/luxbank.ico") root.iconbitmap("application/luxbank.ico")
root.geometry("800x400") root.geometry("800x450")
if CONFIG["preferences"]["dark_theme"] == "dark": # Check if dark mode is enabled if CONFIG["preferences"]["dark_theme"] == "dark": # Check if dark mode is enabled
customtkinter.set_appearance_mode("dark") # Set the style for dark mode customtkinter.set_appearance_mode("dark") # Set the style for dark mode
@@ -168,6 +171,12 @@ else:
welcome_label = customtkinter.CTkLabel(root, text=f"Transactions for: {account_description}", font=("Helvetica", 24)) welcome_label = customtkinter.CTkLabel(root, text=f"Transactions for: {account_description}", font=("Helvetica", 24))
welcome_label.pack(pady=10) welcome_label.pack(pady=10)
# Create the notes label and text box
notes_label = customtkinter.CTkLabel(root, text="Notes:", font=("Helvetica", 14))
notes_label.pack(pady=10)
notes_text = customtkinter.CTkLabel(root, height=4, width=50, wraplength=400) # Use CTkLabel instead of CTkTextbox
notes_text.pack(pady=10, fill=tk.BOTH, expand=True) # Add fill and expand options
# Display account information # Display account information
info_frame = customtkinter.CTkFrame(root) info_frame = customtkinter.CTkFrame(root)
info_frame.pack(fill=tk.X) info_frame.pack(fill=tk.X)

View File

@@ -9,5 +9,5 @@ theme = dark-blue
[client] [client]
default_id = d18e5ae0 default_id = d18e5ae0
default_password = Happymeal1 default_password = KFCKrusher1

View File

@@ -1,10 +1,11 @@
# Lucas Mathews - Fontys Student ID: 5023572 # Lucas Mathews - Fontys Student ID: 5023572
# Banking System Connection Page # Banking System Connection Page
import json
import requests import requests
import hashlib
from requests.models import Response from requests.models import Response
from config import CONFIG from config import CONFIG
import json
from tkinter import messagebox from tkinter import messagebox
############## ##############
@@ -15,22 +16,25 @@ def format_balance(balance):
"""Formats the balance as a currency string with comma separators.""" """Formats the balance as a currency string with comma separators."""
return f"{balance:,.2f}" return f"{balance:,.2f}"
def hash_password(password:str):
"""Hashes a password using the SHA-512 algorithm and returns the hexadecimal representation of the hash."""
return hashlib.sha512(password.encode()).hexdigest()
##################### #####################
### API Functions ### ### API Functions ###
##################### #####################
def authenticate_client(client_id, client_password): def authenticate_client(client_id, client_hash):
"""Authenticates a client with the given client_id and client_password.""" """Authenticates a client with the given client_id and client_hash."""
try: try:
response = requests.post(CONFIG["server"]["url"] + "/Client/Login", params={'client_id': client_id, 'password': client_password}) response = requests.post(CONFIG["server"]["url"] + "/Client/Login", json={'client_id': client_id, 'client_hash': client_hash})
response.raise_for_status()
if response.status_code == 401:
return {'success': False, 'message': "Incorrect password."}
return response return response
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f"RequestException: {e}") raise e # Re-raise the exception to handle it in the login function
response = Response()
response.status_code = 500
response._content = b'{"success": false, "message": "Could not connect to the server. Please try again later."}'
return response
def logout_client(): def logout_client():
"""Logs out the current client.""" """Logs out the current client."""
try: try:
@@ -175,4 +179,41 @@ def generate_otp():
response.raise_for_status() response.raise_for_status()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f"RequestException: {e}") print(f"RequestException: {e}")
messagebox.showerror("Error", f"Could not generate OTP: {e}") messagebox.showerror("Error", f"Could not generate OTP: {e}")
def change_password(client_id, old_password, new_password, otp_code):
"""Changes the password for the given client_id."""
hash_old_password = hash_password(old_password)
hash_new_password = hash_password(new_password)
try:
otp_code = int(otp_code) # Ensure otp_code is an integer
except ValueError:
return {'success': False, 'message': "Invalid OTP code format: must be an integer."}
try:
with open('application\\session_data.json', 'r') as f:
session_data = json.load(f)
payload = { # Prepare the payload to be sent in the request body
'client_id': client_id,
'hash_old_password': hash_old_password,
'hash_new_password': hash_new_password,
'otp_code': otp_code
}
response = requests.put( # Send the PUT request with the payload in the body
CONFIG["server"]["url"] + "/Client/Password",
cookies=session_data['session_cookie'],
json=payload # use json to send the data in the request body
)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if response.status_code == 400:
return {'success': False, 'message': response.json().get('message', 'Invalid request.')}
elif response.status_code == 401:
return {'success': False, 'message': response.json().get('message', 'Unauthorised action.')}
elif response.status_code == 404:
return {'success': False, 'message': response.json().get('message', 'Client not found.')}
else:
return {'success': False, 'message': "An error occurred. Please try again later."}
except requests.exceptions.RequestException as e:
return {'success': False, 'message': "Could not connect to the server. Please try again later."}

View File

@@ -6,7 +6,7 @@ import customtkinter
import json import json
import os import os
from config import CONFIG from config import CONFIG
from connection import logout_client, get_client, update_client, get_accounts, format_balance, generate_otp from connection import logout_client, get_client, update_client, get_accounts, format_balance, generate_otp, change_password
# Global variables # Global variables
@@ -112,7 +112,7 @@ def edit_details():
otp_label.pack() otp_label.pack()
otp_entry.pack() otp_entry.pack()
save_button = customtkinter.CTkButton(edit_window, text="Verify OTP and Save", command=save_details) save_button = customtkinter.CTkButton(edit_window, text="Verify OTP and Save", command=change_password_save)
save_button.pack() save_button.pack()
edit_window.lift() edit_window.lift()
@@ -186,6 +186,82 @@ def reload_info_and_accounts():
display_client_info() display_client_info()
populate_table() populate_table()
def change_password_box():
"""Opens a new window for changing the client's password."""
global edit_window,password_entry, old_password_entry, confirm_password_entry, otp_entry
edit_window = customtkinter.CTkToplevel(root)
edit_window.title("Change Password")
edit_window.iconbitmap("application/luxbank.ico")
edit_window.geometry("300x350")
edit_window.attributes('-topmost', True)
old_password_label = customtkinter.CTkLabel(edit_window, text="Old Password: ")
old_password_entry = customtkinter.CTkEntry(edit_window, show="*")
old_password_label.pack()
old_password_entry.pack()
customtkinter.CTkLabel(edit_window, text=" ").pack() # Add space under the old password box
password_label = customtkinter.CTkLabel(edit_window, text="New Password: ")
password_entry = customtkinter.CTkEntry(edit_window, show="*")
password_label.pack()
password_entry.pack()
confirm_password_label = customtkinter.CTkLabel(edit_window, text="Confirm Password: ")
confirm_password_entry = customtkinter.CTkEntry(edit_window, show="*")
confirm_password_label.pack()
confirm_password_entry.pack()
customtkinter.CTkLabel(edit_window, text=" ").pack() # Add space under the confirm password box
otp_button = customtkinter.CTkButton(edit_window, text="Get OTP Code", command=generate_otp)
otp_button.pack()
otp_label = customtkinter.CTkLabel(edit_window, text="OTP Code: ")
otp_entry = customtkinter.CTkEntry(edit_window)
otp_label.pack()
otp_entry.pack()
save_button = customtkinter.CTkButton(edit_window, text="Verify OTP and Save", command=change_password_save)
save_button.pack()
edit_window.lift()
def change_password_save():
"""Saves the updated client password."""
global edit_window, otp_entry, password_entry, old_password_entry, confirm_password_entry
old_password = old_password_entry.get()
new_password = password_entry.get()
confirm_password = confirm_password_entry.get()
otp_code = otp_entry.get()
if not otp_code:
messagebox.showerror("Error", "OTP code must be entered.")
return
if not new_password or not confirm_password:
messagebox.showerror("Error", "New password and confirm password must be entered.")
return
if new_password != confirm_password:
messagebox.showerror("Error", "New password and confirm password do not match.")
return
with open('application\\session_data.json', 'r') as f:
session_data = json.load(f)
client_id = session_data['client_id']
if not messagebox.askyesno("Confirmation", "Are you sure you want to change the password?"):
return
try:
response = change_password(client_id, old_password, new_password, otp_code)
if response['success']:
messagebox.showinfo("Success", "Password changed successfully.")
edit_window.destroy()
else:
messagebox.showerror("Error", f"Could not change password: {response['message']}")
except Exception as e:
messagebox.showerror("Error", f"Could not change password: {str(e)}")
############## ##############
### Layout ### ### Layout ###
@@ -215,13 +291,17 @@ otp_button.grid(row=0, column=0, padx=5)
reload_button = customtkinter.CTkButton(button_frame, text="Reload", command=reload_info_and_accounts) reload_button = customtkinter.CTkButton(button_frame, text="Reload", command=reload_info_and_accounts)
reload_button.grid(row=0, column=1, padx=5) reload_button.grid(row=0, column=1, padx=5)
# Create reset password button
password_button = customtkinter.CTkButton(button_frame, text="Reset Password", command=change_password_box)
password_button.grid(row=0, column=2, padx=5)
# Create the logout button # Create the logout button
logout_button = customtkinter.CTkButton(button_frame, text="Logout", command=logout) logout_button = customtkinter.CTkButton(button_frame, text="Logout", command=logout)
logout_button.grid(row=0, column=2, padx=5) logout_button.grid(row=0, column=3, padx=5)
# Create the exit button # Create the exit button
exit_button = customtkinter.CTkButton(button_frame, text="Exit", command=exit_application) exit_button = customtkinter.CTkButton(button_frame, text="Exit", command=exit_application)
exit_button.grid(row=0, column=3, padx=5) exit_button.grid(row=0, column=4, padx=5)
# Display client info after creating the buttons # Display client info after creating the buttons
frame = customtkinter.CTkFrame(root) frame = customtkinter.CTkFrame(root)

View File

@@ -4,7 +4,7 @@ import customtkinter
import os import os
import json import json
import requests import requests
from connection import authenticate_client from connection import authenticate_client, hash_password
from config import CONFIG from config import CONFIG
import configparser, sys import configparser, sys
@@ -17,10 +17,11 @@ def login():
"""Authenticate the client and open the dashboard if successful.""" """Authenticate the client and open the dashboard if successful."""
client_id = entry_username.get() if entry_username.get() else CONFIG["client"]["default_id"] client_id = entry_username.get() if entry_username.get() else CONFIG["client"]["default_id"]
client_password = entry_password.get() if entry_password.get() else CONFIG["client"]["default_password"] client_password = entry_password.get() if entry_password.get() else CONFIG["client"]["default_password"]
client_hash = hash_password(client_password) # Hash the password on the client-side
try: try:
response = authenticate_client(client_id, client_password) # Authenticate the client response = authenticate_client(client_id, client_hash) # Authenticate the client
json_response = response.json() # Convert the response content to JSON json_response = response.json() # Convert the response content to JSON
if json_response["success"]: # If the authentication is successful, open the dashboard if response.status_code == 200 and json_response.get("success"): # If the authentication is successful, open the dashboard
session_data = { session_data = {
'session_cookie': response.cookies.get_dict(), 'session_cookie': response.cookies.get_dict(),
'client_id': client_id 'client_id': client_id
@@ -29,10 +30,14 @@ def login():
json.dump(session_data, f) json.dump(session_data, f)
root.destroy() root.destroy()
os.system("python application/dashboard.py") os.system("python application/dashboard.py")
elif response.status_code == 401:
messagebox.showerror("Login failed", "Invalid client ID or password.")
else: else:
messagebox.showerror("Login failed", json_response["message"]) messagebox.showerror("Login failed", json_response.get("message", "Unknown error"))
except requests.exceptions.RequestException as e: except requests.exceptions.HTTPError:
messagebox.showerror("Login failed", f"Could not connect to the server. Please try again later. Error: {str(e)}") messagebox.showerror("Login failed", "Invalid client ID or password.")
except requests.exceptions.ConnectionError:
messagebox.showerror("Connection Error", "Could not connect to the server.")
def change_dark_theme(): def change_dark_theme():
"""Change the theme between dark and light.""" """Change the theme between dark and light."""

View File

@@ -1 +1 @@
{"session_cookie": {"session": "nwHUYOr9vg2nOaZmrYNmWgjMgJ47QLIz71_dX_kFH_o"}, "client_id": "d18e5ae0"} {"session_cookie": {"session": "HHymBLOCpW9YTcajilxYA_B9aVPJ1FyS75TAz2995jk"}, "client_id": "d18e5ae0"}

View File

@@ -14,8 +14,6 @@ import sys
############## ##############
### Layout ### ### Layout ###
############## ##############

View File

@@ -6,16 +6,18 @@ from class_account import Account
from class_transaction import Transaction from class_transaction import Transaction
from emailer import EmailSendingError # Import the EmailSendingError class to handle email sending errors from emailer import EmailSendingError # Import the EmailSendingError class to handle email sending errors
from flask import jsonify, session as flask_session # Imports the Flask modules from flask import jsonify, session as flask_session # Imports the Flask modules
from functools import wraps # For decorators / user login
from database import * # Importing the database connection
from emailer import send_email # Importing the emailer function
from flask import session as flask_session
from flask import request
from database import session
import hashlib # For password hashing import hashlib # For password hashing
import datetime # For timestamps import datetime # For timestamps
import uuid # For unique identifiers import uuid # For unique identifiers
import random # For OTP generation import random # For OTP generation
import time # For OTP generation import time # For OTP generation
from functools import wraps # For decorators / user login import re # For password hash validation
from database import * # Importing the database connection
from emailer import send_email # Importing the emailer function
from flask import session as flask_session
from database import session
otps = {} # Temporary dictionary to store OTPs and their creation time otps = {} # Temporary dictionary to store OTPs and their creation time
@@ -41,10 +43,8 @@ def generate_uuid_short():
def get_email(client_id:str): def get_email(client_id:str):
"""Returns the email of a client given their client_id. If the client is not found, returns None.""" """Returns the email of a client given their client_id. If the client is not found, returns None."""
for client in session.query(Client).all(): client = session.query(Client).filter_by(client_id=client_id).one_or_none()
if client.client_id == client_id: return client.email if client else None
return client.email
return None
def format_response(success: bool, message: str = '', data: dict = None): def format_response(success: bool, message: str = '', data: dict = None):
"""Formats the response for the API so that it is standardised across all functions.""" """Formats the response for the API so that it is standardised across all functions."""
@@ -68,10 +68,12 @@ def get_current_client():
def verify_otp(client_id:str, otp:int): def verify_otp(client_id:str, otp:int):
"""Verifies a one time password for a client. Returns True if the OTP is correct and False otherwise.""" """Verifies a one time password for a client. Returns True if the OTP is correct and False otherwise."""
if client_id in otps and otps[client_id][0] == otp: if client_id in otps:
return True stored_otp, creation_time = otps[client_id]
if stored_otp == otp and time.time() - creation_time <= 300: # Check if OTP is within 5 minutes
return True
return False return False
def delete_otp(client_id:str): def delete_otp(client_id:str):
"""Deletes the OTP for a client.""" """Deletes the OTP for a client."""
if client_id in otps: if client_id in otps:
@@ -88,14 +90,18 @@ def check_expired_otps():
### Authentication ### ### Authentication ###
###################### ######################
def login(client_id:str, password:str): def login():
"""Logs in a client using their client_id and password. Returns a success message if the login is successful and an error message otherwise.""" """Logs in a client using their client_id and password hash. Returns a success message if the login is successful and an error message otherwise."""
password_hash = hash_password(password) data = request.get_json()
for client in session.query(Client).all(): client_id = data.get('client_id')
if client.client_id == client_id and client.hash == password_hash: client_hash = data.get('client_hash')
flask_session['client_id'] = client_id
return format_response(True, f"{flask_session['client_id']} logged in succsessfully."), 200 client = session.query(Client).filter_by(client_id=client_id).first()
return format_response(False, "Invalid client_id or password."), 400 if client and client.hash == client_hash:
flask_session['client_id'] = client_id
return format_response(True, f"{flask_session['client_id']} logged in successfully."), 200
return format_response(False, "Invalid client_id or password."), 401
def logout(): def logout():
"""Logs out a client. Returns a success message if the logout is successful and an error message otherwise.""" """Logs out a client. Returns a success message if the logout is successful and an error message otherwise."""
@@ -162,14 +168,16 @@ def generate_otp(client_id: str):
############## ##############
@login_required @login_required
def get_client(client_id:str): def get_client(client_id: str):
"""Returns a specific client in the database. If the client is not found, returns an error message.""" """Returns a specific client in the database. If the client is not found, returns an error message."""
current_client_id, is_admin = get_current_client() current_client_id, is_admin = get_current_client()
if not is_admin and client_id != current_client_id: if not is_admin and client_id != current_client_id:
return format_response(False, "You can only view your own client information."), 403 return format_response(False, "You can only view your own client information."), 403
for client in session.query(Client).all():
if client.client_id == client_id: client = session.query(Client).filter_by(client_id=client_id).one_or_none()
return format_response(True, "", client.to_dict()), 200 if client:
return format_response(True, "", client.to_dict()), 200
return format_response(False, "Client not found."), 404 return format_response(False, "Client not found."), 404
@login_required @login_required
@@ -192,25 +200,44 @@ def update_client(client_id:str, otp_code:int, **kwargs):
return format_response(False, "Client not found."), 404 return format_response(False, "Client not found."), 404
@login_required def change_password():
def change_password(client_id:str, password:str, new_password:str, otp:int):
"""Changes the password for a client in the database. If the client is not found, returns an error message.""" """Changes the password for a client in the database. If the client is not found, returns an error message."""
data = request.get_json()
client_id = data.get('client_id')
hash_old_password = data.get('hash_old_password')
hash_new_password = data.get('hash_new_password')
otp_code = data.get('otp_code')
current_client_id, is_admin = get_current_client() current_client_id, is_admin = get_current_client()
# Verify if the OTP is correct
otp_verified = verify_otp(client_id, otp_code)
# Check if the client is authorized to change the password
if not is_admin and client_id != current_client_id: if not is_admin and client_id != current_client_id:
return format_response(False, "You can only update your own client information."), 403 return format_response(False, "You can only update your own client information."), 401
if not verify_otp(client_id, otp):
return format_response(False, "Invalid OTP."), 400 # Recheck OTP verification after authorisation check
old_hash = hash_password(password) if not otp_verified:
new_hash = hash_password(new_password) return format_response(False, "Invalid OTP."), 400
for client in session.query(Client).all():
if client.client_id == client_id: # Validate new password format
if client.hash == old_hash: hash_format = r'^[0-9a-f]{128}$'
client.hash = new_hash if not re.match(hash_format, hash_new_password):
session.commit() return format_response(False, "Invalid new password format (must be provided as a hash)."), 400
delete_otp(client_id)
return format_response(True, f"Password for client_id: {client_id} has been updated."), 200 # Check if the old password hash matches and update to the new password hash
return format_response(False, "Invalid password."), 400 client = session.query(Client).filter_by(client_id=client_id).first()
return format_response(False, "Client not found."), 404 if client:
if client.hash == hash_old_password:
client.hash = hash_new_password
session.commit()
delete_otp(client_id)
return format_response(True, f"Password for client_id: {client_id} has been updated."), 200
else:
return format_response(False, "Invalid old password."), 400
else:
return format_response(False, "Client not found."), 404
@login_required @login_required
def get_accounts(client_id: str): def get_accounts(client_id: str):

Binary file not shown.