diff --git a/api.yml b/api.yml index 1fd6357..048b9b1 100644 --- a/api.yml +++ b/api.yml @@ -29,24 +29,46 @@ paths: summary: Log in to the system description: Log in to the system operationId: manager.login - parameters: - - name: client_id - in: query - description: client_id - required: true - schema: - type: string - - name: password - in: query - description: Password - required: true - schema: - type: string + requestBody: + description: Login credentials + required: true + content: + application/json: + schema: + type: object + properties: + client_id: + type: string + description: Client ID + client_hash: + type: string + description: Hashed password + required: + - client_id + - client_hash responses: '200': description: Successful operation + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string '401': description: Invalid Client ID/password supplied + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string /Client/Logout: post: tags: @@ -78,39 +100,40 @@ paths: summary: Change password description: Change password operationId: manager.change_password - parameters: - - name: client_id - in: query - description: ID of client to change password - required: true - schema: - type: string - - name: password - in: query - description: New password - required: true - schema: - type: string - - name: new_password - in: query - description: New password - required: true - schema: - type: string - - name: otp - in: query - description: OTP to verify - required: true - schema: - type: integer - format: int32 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + client_id: + type: string + description: ID of client to change password + hash_old_password: + type: string + description: Old password as a hash + hash_new_password: + type: string + description: New password as a hash + otp_code: + type: integer + format: int32 + description: OTP to verify + required: + - client_id + - hash_old_password + - hash_new_password + - otp_code responses: '200': description: Password changed successfully '400': - description: Old password incorrect + description: Validation error + '401': + description: Unauthorised '404': - description: client_id not found + description: Client not found /OTP/Generate: post: tags: diff --git a/application/__pycache__/connection.cpython-312.pyc b/application/__pycache__/connection.cpython-312.pyc index 60315ba..c33f362 100644 Binary files a/application/__pycache__/connection.cpython-312.pyc and b/application/__pycache__/connection.cpython-312.pyc differ diff --git a/application/account.py b/application/account.py index a321b95..badde45 100644 --- a/application/account.py +++ b/application/account.py @@ -12,6 +12,7 @@ from connection import get_transactions, format_balance, get_account, generate_o description_entry = None notes_entry = None otp_entry = None +notes_text = None ################# ### Functions ### @@ -64,6 +65,8 @@ def display_account_info(account_id): label_value = customtkinter.CTkLabel(info_frame, text=value, font=("Helvetica", 14)) 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) + global notes_text + notes_text.configure(text=account.get('notes', '')) # Use config instead of configuration def on_transaction_double_click(event): """Handles double-click event on a transaction in the table.""" @@ -157,7 +160,7 @@ def save_details(): root = customtkinter.CTk() root.title(f"Transactions for: {account_description}") root.iconbitmap("application/luxbank.ico") -root.geometry("800x400") +root.geometry("800x450") if CONFIG["preferences"]["dark_theme"] == "dark": # Check if dark mode is enabled 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.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 info_frame = customtkinter.CTkFrame(root) info_frame.pack(fill=tk.X) diff --git a/application/app.ini b/application/app.ini index 00e8b3a..e0ea061 100644 --- a/application/app.ini +++ b/application/app.ini @@ -9,5 +9,5 @@ theme = dark-blue [client] default_id = d18e5ae0 -default_password = Happymeal1 +default_password = KFCKrusher1 diff --git a/application/connection.py b/application/connection.py index 60a41d7..982b451 100644 --- a/application/connection.py +++ b/application/connection.py @@ -1,10 +1,11 @@ # Lucas Mathews - Fontys Student ID: 5023572 # Banking System Connection Page +import json import requests +import hashlib from requests.models import Response from config import CONFIG -import json from tkinter import messagebox ############## @@ -15,22 +16,25 @@ def format_balance(balance): """Formats the balance as a currency string with comma separators.""" 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 ### ##################### -def authenticate_client(client_id, client_password): - """Authenticates a client with the given client_id and client_password.""" +def authenticate_client(client_id, client_hash): + """Authenticates a client with the given client_id and client_hash.""" 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 except requests.exceptions.RequestException as e: - print(f"RequestException: {e}") - response = Response() - response.status_code = 500 - response._content = b'{"success": false, "message": "Could not connect to the server. Please try again later."}' - return response - + raise e # Re-raise the exception to handle it in the login function + def logout_client(): """Logs out the current client.""" try: @@ -175,4 +179,41 @@ def generate_otp(): response.raise_for_status() except requests.exceptions.RequestException as e: print(f"RequestException: {e}") - messagebox.showerror("Error", f"Could not generate OTP: {e}") \ No newline at end of file + 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."} diff --git a/application/dashboard.py b/application/dashboard.py index bf44a45..2f008a9 100644 --- a/application/dashboard.py +++ b/application/dashboard.py @@ -6,7 +6,7 @@ import customtkinter import json import os 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 @@ -112,7 +112,7 @@ def edit_details(): otp_label.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() edit_window.lift() @@ -186,6 +186,82 @@ def reload_info_and_accounts(): display_client_info() 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 ### @@ -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.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 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 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 frame = customtkinter.CTkFrame(root) diff --git a/application/login.py b/application/login.py index 4a02854..0fe2bf2 100644 --- a/application/login.py +++ b/application/login.py @@ -4,7 +4,7 @@ import customtkinter import os import json import requests -from connection import authenticate_client +from connection import authenticate_client, hash_password from config import CONFIG import configparser, sys @@ -17,10 +17,11 @@ def login(): """Authenticate the client and open the dashboard if successful.""" 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_hash = hash_password(client_password) # Hash the password on the client-side 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 - 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_cookie': response.cookies.get_dict(), 'client_id': client_id @@ -29,10 +30,14 @@ def login(): json.dump(session_data, f) root.destroy() os.system("python application/dashboard.py") + elif response.status_code == 401: + messagebox.showerror("Login failed", "Invalid client ID or password.") else: - messagebox.showerror("Login failed", json_response["message"]) - except requests.exceptions.RequestException as e: - messagebox.showerror("Login failed", f"Could not connect to the server. Please try again later. Error: {str(e)}") + messagebox.showerror("Login failed", json_response.get("message", "Unknown error")) + except requests.exceptions.HTTPError: + 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(): """Change the theme between dark and light.""" diff --git a/application/session_data.json b/application/session_data.json index 7ff4dbb..0902470 100644 --- a/application/session_data.json +++ b/application/session_data.json @@ -1 +1 @@ -{"session_cookie": {"session": "nwHUYOr9vg2nOaZmrYNmWgjMgJ47QLIz71_dX_kFH_o"}, "client_id": "d18e5ae0"} \ No newline at end of file +{"session_cookie": {"session": "HHymBLOCpW9YTcajilxYA_B9aVPJ1FyS75TAz2995jk"}, "client_id": "d18e5ae0"} \ No newline at end of file diff --git a/application/transaction.py b/application/transaction.py index 0a549aa..7871030 100644 --- a/application/transaction.py +++ b/application/transaction.py @@ -14,8 +14,6 @@ import sys - - ############## ### Layout ### ############## diff --git a/manager.py b/manager.py index e75ca16..f823b47 100644 --- a/manager.py +++ b/manager.py @@ -6,16 +6,18 @@ from class_account import Account from class_transaction import Transaction 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 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 datetime # For timestamps import uuid # For unique identifiers import random # For OTP generation import time # For OTP generation -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 database import session +import re # For password hash validation otps = {} # Temporary dictionary to store OTPs and their creation time @@ -41,10 +43,8 @@ def generate_uuid_short(): def get_email(client_id:str): """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(): - if client.client_id == client_id: - return client.email - return None + client = session.query(Client).filter_by(client_id=client_id).one_or_none() + return client.email if client else 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.""" @@ -68,10 +68,12 @@ def get_current_client(): 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.""" - if client_id in otps and otps[client_id][0] == otp: - return True + if client_id in otps: + 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 - + def delete_otp(client_id:str): """Deletes the OTP for a client.""" if client_id in otps: @@ -88,14 +90,18 @@ def check_expired_otps(): ### Authentication ### ###################### -def login(client_id:str, password:str): - """Logs in a client using their client_id and password. Returns a success message if the login is successful and an error message otherwise.""" - password_hash = hash_password(password) - for client in session.query(Client).all(): - if client.client_id == client_id and client.hash == password_hash: - flask_session['client_id'] = client_id - return format_response(True, f"{flask_session['client_id']} logged in succsessfully."), 200 - return format_response(False, "Invalid client_id or password."), 400 +def login(): + """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.""" + data = request.get_json() + client_id = data.get('client_id') + client_hash = data.get('client_hash') + + client = session.query(Client).filter_by(client_id=client_id).first() + 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(): """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 -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.""" current_client_id, is_admin = get_current_client() if not is_admin and client_id != current_client_id: 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: - return format_response(True, "", client.to_dict()), 200 + + client = session.query(Client).filter_by(client_id=client_id).one_or_none() + if client: + return format_response(True, "", client.to_dict()), 200 + return format_response(False, "Client not found."), 404 @login_required @@ -192,25 +200,44 @@ def update_client(client_id:str, otp_code:int, **kwargs): return format_response(False, "Client not found."), 404 -@login_required -def change_password(client_id:str, password:str, new_password:str, otp:int): +def change_password(): """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() + + # 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: - return format_response(False, "You can only update your own client information."), 403 - if not verify_otp(client_id, otp): - return format_response(False, "Invalid OTP."), 400 - old_hash = hash_password(password) - new_hash = hash_password(new_password) - for client in session.query(Client).all(): - if client.client_id == client_id: - if client.hash == old_hash: - client.hash = new_hash - session.commit() - delete_otp(client_id) - return format_response(True, f"Password for client_id: {client_id} has been updated."), 200 - return format_response(False, "Invalid password."), 400 - return format_response(False, "Client not found."), 404 + return format_response(False, "You can only update your own client information."), 401 + + # Recheck OTP verification after authorisation check + if not otp_verified: + return format_response(False, "Invalid OTP."), 400 + + # Validate new password format + hash_format = r'^[0-9a-f]{128}$' + if not re.match(hash_format, hash_new_password): + return format_response(False, "Invalid new password format (must be provided as a hash)."), 400 + + # Check if the old password hash matches and update to the new password hash + client = session.query(Client).filter_by(client_id=client_id).first() + 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 def get_accounts(client_id: str): diff --git a/test_database.db b/test_database.db index 7fe9cd2..83e5c1f 100644 Binary files a/test_database.db and b/test_database.db differ