I just needed to have some project to help me showcase my cybersecurity learnings and findings, so one of the projects I decided on making was this one: creating a Secure Login System via Tkinter and a Yubikey that I got from Hack Club (...for free!).
The goal of this project was to further solidify some of the cybersecurity concepts I know, such as authentication and authorization, into a project that I can show to others. Another goal of this project is for me to learn about how Python's Tkinter works, so that I can use it in the future if I need to create mock-ups for apps or for other uses.
Since I almost never experiemented with Tkinter before, I used this GeeksForGeeks article outlining that basic structure of a "modern login UI" for Tkinter. However, although this "starter" GUI (Graphical User Interface) app, I couldn't build upon it into a multi-page application. So, with the help of AI, I transferred the current code that I made with the GeeksForGeeks template into a OOP (Object-Oriented Programming) format, where I learned to implications and universality of OOP in app development. So, I learned that most app development platform uses this similar structure of inheritance and structural hierarchies in OOP. Inheritance is where a child class (aka subclass) inherits attributes and behaviors (methods) from a parent class (aka base class).
One of the most things I learned about important things i learned about Tkynter (specifically CustomTkinter) was its structure. It goes like this:
.pack()
.grid()
.place()
CustomTkinter actually is a CHILD class from Tkinter, so most of the attributes and behaviors works the same between the two classes.
However, when attempting to implement OOP into my app, I ran into a problem with the scoping, where I needed certain variables to be globally accessible, or at least accessible to pages that need it.
class LoginPage(ctk.CTkFrame):
def __init__(self, master, controller):
...
def login(self):
...
status = authentication(username) if authentication(username) != None else tkmb.showerror(title="Login Failed", message="Invalid Username")
So after consulting ChatGPT to find a solution to this problem, I used the controller attribute of the app page classes to "share" the status (as in ranking, not in condition) and status variables, which were updated and used across the login screen page to the dashboard page.
“controller is a centralized parameter used in a GUI pattern that partially breaks encapsulation, but provides clean enough structure for small to medium GUI apps.”
Error #1: AttributeError: '_tkinter.tkapp' object has no attribute 'username'
Line: permissions, personals = authorization(self.controller.username, self.controller.status)
The problem was in the username, where I didn't understand how the controller object worked. I thought that the controller object was supposed to be retained throughout all the app classes, but I was wrong. So in order to solve this bug, I had to be able to answer: "How am I supposed to share variables across different classes?"
class LoginPage(ctk.CTkFrame):
def __init__(self, master, controller):
...
self.username = ctk.CTkEntry(self, placeholder_text="Username")
self.username.pack(pady=8)
self.password = ctk.CTkEntry(self, placeholder_text="Password", show="*")
self.password.pack(pady=8)
...
def login(self):
...
self.controller.username, username = self.username.get()
The organization looked very complicated, so after debugging for a couple of hours, I finally found out that controller, the entity where I could get shared variables from, acts as a namespace (like from cpp), instead of being a variable itself (which was what I thought). And actually, any attribute or method of MainApp (the main class from which the GUI app an object of) is accessible via self.controller within page classes (e.g., LoginPage, DashboardPage).
Error #2: TypeError: 'NoneType' object cannot be interpreted as an integer
Line: for i in range(power, 0, -1): ...
The code only reaches this line from after the username and password was inputted, so I first suspected that this error was happening because the app window itself never launched.
Well, actually, the app did launch, so maybe the method (i.e., authorization(username, power)) executed before I could even input the username and password. So after commenting out that line, the app launched as normal, with login functionality as well!
Note: The "power" variable represents the amount of authority the person has.
What the real problem was, as I would discover later, was how the code for retrieving the username and password was in its __init__ method, which meant that the code will search for the username and password right when the GUI application is launched before the user could even get a chance to type in their username and password.
def __init__(self, master, controller):
...
username = self.controller.shared_data.get("username")
power = self.controller.shared_data.get("power")
permissions, personals = authorization(username, power)
As seen, the method authorization() would try to retrieve the permissions and personal permissions that the user will have access to, depending on their username and power, which shouldn't be NoneType in order to work. Thus, the program threw a 'NoneType' error, which is what I saw from the beginning.
So in order to solve this problem, I get help from AI and ended up editing the original MainApp class in order to implement a default "refresh" member method for pages that need it (i.e., the DashboardPage). After this was implemented in the DashboardPage class, the code worked as expected.
def refresh(self):
# Checking for existence
username = self.controller.shared_data.get("username")
power = self.controller.shared_data.get("power")
if not username or not power:
self.welcome_label.configure(text="User not logged in")
self.status_label.configure(text="")
return
# Call authorization once
result = authorization(username, power)
if result is not None:
permissions, personals = result
Error #3: Not a traditional error, but a logic error
When I open the app initially, there already is text showing that was only supposed to be shown after an unsuccessful login attempt. This is when I noticed that in the "refresh" method of the DashboardPage class, this could've been a result of that method running immediately after the app was run. Then, after commenting out that section of code that would have displayed the "User not logged in" text onto the login page, sure enough, a 'NoneType' TypeError was thrown, due to the authorization() function being called right after the code that had displayed the text. So, in order to avoid the TypeError, I uncommented the code that displayed the text, and deleted the text, leaving an empty "".
def show_frame(self, page_name):
for frame in self.pages.values():
frame.pack_forget()
self.pages[page_name].pack(fill="both", expand=True)
if hasattr(frame, "refresh"):
frame.refresh()
frame.pack(fill="both", expand=True)
That didn't work either, it just hid the effects. Then, I looked over to the show_frame method in the main MainApp class. There, I found that the .pack() method is used twice. This is the method that is used to organize widgets (elements) in the application. So, by "pack"ing twice, I display two widgets on the board, which explains the uninvited text in the login page.
Also, I didn't know this before, but apparently Python saves loop variables (e.g., the "i" in for i in range(3)), so that became a problem in the last line of code, because the last page class that was iterated (i.e., the DashboardPage class) was going to have its refresh method run.
After dealing with all of this mess, I finally moved on to implementing functionality with the YubiKey.
Well--before implementing the use of my YubiKey, I decided to add password hashing and salting before doing so, to improve upon the authentication method that I currently have (plaintext passwords).
After doing some research, I decided on choosing argon2, a famously effective tool for hashing and salting, to hash and salt the passwords for my GUI app.
# Get stored hashed password (password checking v2!)
stored_hash = password_map[username]
# Check password using Argon2
if not verify_password(stored_hash, password):
tkmb.showwarning("Wrong Password", "Please check your password")
return
Also, while implementing argon2 in my login system, I had to fix a bug, where whenever I was trying to log in with a valid username and password the program would throw a InvalidHashError, and I later found out that it was because of this line:
stored_hash = password_map[username]
I was assuming that all of the passwords were salted and hashed in-place as values in the password_map dictionary, but the hashes were actually the values in the LoginPage's self.stored_hashes dictionary.
stored_hash = self.stored_hashes[username]
Additionally, I thought I would like to create a way to "sign up" for such a new "account" for my simulator, to make a bit more realistic, so I got to work on that also.
Fortunately, I managed to code and debug the whole "CreateAccount" page class (because of all of the other existing page classes): most of the debugging was mainly from just paying more attention to the syntax and structure of the code.
For example, when creating the submit method for the CreateAccount page class, I assumed that using this code would be ok:
def submit(self):
password_map[self.username] = hash_password(self.password)
But it's actually not; this would be the correct code:
def submit(self):
username = self.username.get()
password = self.password.get()
password_map[username] = hash_password(password)
...because of how was unknowingly omitting the .get() method before.
I later realized that in my authentication method, I had hard-coded the username account's names into their status'/authorities'. Now, I had to make the method more flexible. By doing so, it kinda defeated the purpose of having that method in the first place, but I'll keep it to show the different cybersecurity functions.
def authentication(uname):
return authorities[uname]
Also, due to me having to access the stored_hashes dictionary again in a page other than the LoginPage (i.e., CreateAccount), I had to make stored_hashes saved in the overall MainApp class.
self.controller.stored_hashes = {name: hash_password(password_map[name]) for name in password_map}
And to keep the new accounts' passwords to remain hidden, I adjusted this guard case in the login() method to look if a given username has a hash instead of a password, making it possible to not have to record the plaintext version of the password.
if username not in self.controller.stored_hashes:
tkmb.showerror("Login Failed", "Invalid Username")
return
Here we go!
As I plugged in my YubiKey 5C NFC for the first time, I saw the YubiKey Manager pop up, conforming that I've plugged it in, and I then followed a quick tutorial using perplexity.ai for help for setting up my YubiKey.
When configuring my YubiKey, there was a part of the process where I had to press the physical key in order to enter a OTP (one-time password) in order to It's worth mentioning, though, that there are TWO different types of OTPs that a YubiKey (at least my version) can generate: a "short" touch and a "long" touch version. Since I didn't hold the YubiKey with my finger for long enough, it inputted the "short" touch OTP, which led to me getting my key upload being denied for the first two attempts :(
Finally, after signing up for a Yubico API key to get my Client ID and Secret Key, I started work on creating the YubiKey (OTP) option for my login simulator. For the YubiKey login, I created a separate page for logging in with the YubiKey, to make things more modulated and separated, and I even made the header of the page a bit more interesting than the other pages ;)
class YubiKeyLoginPage(ctk.CTkFrame):
def __init__(self, master, controller):
super().__init__(master)
self.controller = controller
label = ctk.CTkLabel(self, text="!! Restricted Access !!")
label.pack(pady=12)
self.otp = ctk.CTkEntry(self, placeholder_text="OTP")
self.otp.pack(pady=8)
submit_btn = ctk.CTkButton(self, text="Submit", command=self.submit)
submit_btn.pack(pady=8)
...
Eventually, I got the OTPs and the YubiKey integration to work, but I now have to work on getting th app to recognize that the YubiKey login