Out of boredom, I decided to start my personal project and I've chosen a simple Text Password Manager.
Note: For anyone out there, I strongly recommend you NOT to use this for any sensitive storage purposes as it DOESN'T provide encryption yet!. That'll probably come in a later release.
About Safer
My project is going to be called Safer and these are the tools I've used so far:
- Python 3.8
- SQLAlchemy
- SQLite3
Current features:
- Retrieve all saved passwords.
- Create a new password.
- Retrieve a single password (by its name).
- Update a single password (by its name).
- Delete a single password (by its name).
Upcoming features (out of this review's purpose but it gives the reviewer some context):
- Do all of the above only if a master password is provided (and it also matches the one from the DB).
- Create a master password if it doesn't exist.
- Encrypt all the passwords.
What I'd like to get out of this review:
- Is there a better way to restructure this project?
- Are the project files named correctly?
- Is my code modular enough?
- What about the logic? Would you use other approach over another when it comes any of the functionality in my code?
- Did I stick to the DRY principle enough? If not, what can I improve?
- Have I used SqlAlchemy as I should've?
- UX - User experience
- Wherever is room from improvement, please do tell ^_^
Right now, my project looks like this:
├── README.md
├── backend
│ ├── __init__.py // nothing here
│ ├── main.py // run program from here (will probably be moved to root dir in the future)
│ ├── models.py // all the models used by SQLAlchemy
│ └── views.py // not really views, actions for my models.
├── config.py // store all the needed configs here
├── requirements.txt // self-explanatory
├── safer.db // sqlite db file
└── setup.cfg // various pep8, style, type-annotations config
The code:
main.py
"""Main entry to our app.
Contains all the needed calls.
"""
from typing import Optional, Iterable
import sys
from getpass import getpass
from views import (
create_master_password,
create_password,
delete_password,
get_password_by_name,
is_master_password_valid,
list_all_passwords,
update_password,
)
VALID_MASTER_PASS_ANSWERS = (
"Y",
"y",
"Yes",
"yes",
"N",
"n",
"No",
"no",
)
VALID_ACTIONS = (
"1",
"2",
"3",
"4",
"5",
"9",
)
def get_name(prompt: str) -> str:
"""Keep asking for a valid name until one is given.
Arguments:
prompt (str): Prompt message.
Returns:
string - name of the password
"""
while True:
name = input(prompt)
if not name:
print(
"Name cannot be empty. We suggest you insert a "
"descriptive name for your password."
)
continue
return name
def get_password(prompt: str) -> str:
"""Keep asking for a valid password until one is given.
Arguments:
prompt (str): Prompt message.
Returns:
string - password
"""
while True:
password = getpass(prompt)
if not password:
print("Password cannot be empty.")
continue
if len(password) < 8:
print("WARNING! This is a weak password.")
return password
def get_option(prompt: str, options: Optional[Iterable[str]] = None) -> str:
"""Keep asking for a valid option until one is given.
Arguments:
prompt (str): Prompt message.
options (tuple): Options to choose from
Returns:
string - valid option
"""
while True:
option = input(prompt)
if not option:
print("Please enter an option.")
continue
if option not in options:
valid_options = ", ".join(options)
print(f"Invalid option. Valid options: {valid_options}")
continue
return option
def main() -> None:
"""Main entry to our program."""
has_master_password = get_option(
"Do you have a master password? [Y/n]: ",
options=VALID_MASTER_PASS_ANSWERS,
)
if has_master_password in ("Y", "y", "Yes", "yes"):
master_password = getpass("Insert your master password: ")
if not is_master_password_valid(master_password):
raise ValueError("Please insert a valid master key.")
what_next = get_option(
"""Choose your next action:
1. View all passwords.
2. Create new password.
3. Show password by name.
4. Update password by name.
5. Delete password by name.
9. Quit
> """,
options=VALID_ACTIONS,
)
if what_next == "1":
list_all_passwords()
if what_next == "2":
name = get_name("New password name (unique!): ")
value = get_password("New password: ")
create_password(name, value)
if what_next == "3":
name = get_name("Password name: ")
get_password_by_name(name)
if what_next == "4":
name = get_name("Password name: ")
value = get_password("New password: ")
update_password(name, value)
if what_next == "5":
name = get_name("Password name: ")
delete_password(name)
if what_next == "9":
sys.exit()
else:
master_password = getpass("Insert your new master password: ")
create_master_password(master_password)
if __name__ == "__main__":
main()
views.py
"""Views module.
Contains basic actions that can be done against
MasterPassword and Password models.
"""
from typing import Any, Optional, Tuple, Union
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from tabulate import tabulate
from config import SQLITE_FILEPATH
from models import Base, MasterPassword, Password
ENGINE = create_engine(SQLITE_FILEPATH)
Base.metadata.create_all(ENGINE)
Session = sessionmaker(bind=ENGINE)
class SaferSession:
"""Context manager for ease of session management."""
def __init__(
self, record: Optional[Union[MasterPassword, Password]] = None
) -> None:
"""Simple constructor.
Arguments:
record (tuple): Optional argument used if provided.
Returns:
None
"""
self.record = record
def __enter__(self) -> sessionmaker():
"""Create a session object and return it.
Returns:
session object
"""
self.session = Session()
return self.session
def __exit__(self, *args: Tuple[None]) -> None:
"""Make sure the session object gets closed properly.
Arguments:
args (tuple): Not really used. Can be None as well.
Returns:
None
"""
if self.record:
self.session.add(self.record)
self.session.commit()
self.session.close()
def create_master_password(master_password: str) -> None:
"""Create a master password.
Arguments:
master_password (str): Desired master password
Returns:
None
"""
with SaferSession(record=MasterPassword(value=master_password)):
print("Master password has been created!")
def create_password(name: str, value: str) -> None:
"""Create a password and a name for it.
Arguments:
name (str): Name of the password.
value (str): The password.
Returns:
None
"""
with SaferSession(record=Password(name, value)):
print(f"Successfully added {name} record.")
def is_master_password_valid(master_password: str) -> Optional[bool]:
"""Check if provided master password is valid or not.
Arguments:
master_password (str): The master password.
Returns:
True if the password matches or None otherwise
"""
with SaferSession() as session:
password_obj = session.query(MasterPassword).one_or_none()
return password_obj.value == master_password if password_obj else None
def get_password_by_name(name: str) -> Any:
"""Get a password by its name.
Arguments:
name (str): Name of the password.
Returns:
password or None
"""
with SaferSession() as session:
try:
password = session.query(Password)
password = password.filter_by(name=name).first().value
except AttributeError:
password = None
print(f"{name} could not be found!")
return password
def update_password(name: str, new_value: str) -> None:
"""Update a specific password.
Arguments:
name (str): Name of the password that needs updating.
new_value (str): New password.
Returns:
None
"""
with SaferSession() as session:
try:
password = session.query(Password).filter_by(name=name).first()
password.value = new_value
print(f"Successfully updated {name} record.")
except AttributeError:
print(f"{name} could not be found!")
return
def delete_password(name: str) -> None:
"""Delete a specific password.
Arguments:
name (str): NAme of the password that needs to be deleted.
Returns:
None
"""
with SaferSession() as session:
try:
session.query(Password).filter(Password.name == name).delete()
print(f"Successfully deleted {name} record.")
except AttributeError:
print(f"{name} could not be found!")
return
def list_all_passwords() -> None:
"""List all passwords.
Returns:
None
"""
with SaferSession() as session:
passwords = session.query(Password).all()
if not passwords:
print("No passwords stored yet!")
return
table = [
[password_obj.name, password_obj.value]
for password_obj in passwords
]
print(tabulate(table, ["Password Name", "Password"], tablefmt="grid"))
models.py
"""Models module.
Contains all the needed models.
"""
from sqlalchemy import Column, DateTime, Integer, String, func
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Password(Base):
"""Password model."""
__tablename__ = "passwords"
id = Column(Integer, primary_key=True)
name = Column(String(128), nullable=False, unique=True)
value = Column(String, nullable=False)
updated = Column(DateTime, default=func.now())
def __init__(self, name: str, value: str) -> None:
"""Simple constructor
Arguments:
name (str): Name of the password.
value (str): Password.
Returns:
None
"""
self.name = name
self.value = value
def __repr__(self) -> str:
"""Representation of the Password object.
Returns:
Representation of the Password object as str
"""
return f"<Password(name='{self.name}', value='{self.value}')>"
class MasterPassword(Base):
"""Master Password model."""
__tablename__ = "master_password"
id = Column(Integer, primary_key=True)
value = Column(String, nullable=False)
updated_at = Column(DateTime, default=func.now())
def __init__(self, value: str) -> None:
"""Simple constructor.
Arguments:
value (str): Master password.
Returns:
None
"""
self.value = value
def __repr__(self) -> str:
"""Representation of the Master Password object.
Returns:
Representation of the Master Password object as str
"""
return f"<MasterPassword(value='{self.value}')>"
config.py
SQLITE_FILEPATH = 'sqlite:////path_to_project_root/safer.db'
setup.cfg
[pylama]
linters = mccabe,pep8,pycodestyle,pyflakes,mypy,isort
ignore=W293
[pylama:*/__init__.py]
ignore=W0611
[pylama:pydocstyle]
convention = google
[pylama:mccabe]
max-complexity = 2
[pydocstyle]
convention = google
[isort]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=79
[mypy]
check_untyped_defs = true
disallow_any_generics = true
disallow_untyped_defs = true
ignore_missing_imports = true
no_implicit_optional = true
warn_redundant_casts = true
warn_return_any = true
warn_unused_ignores = true
You can also clone the project from here. Don't forget to change the path in the config.py!