diff --git a/.gitignore b/.gitignore index 731f667..a0c28fc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ __pycache__/flower_vase.cpython-314.pyc __pycache__/flowers.cpython-314.pyc __pycache__/FlowerServices.cpython-314.pyc __pycache__/Log.cpython-314.pyc +__pycache__/ +tests/__pycache__/ +.pytest_cache/ +secrets.yaml diff --git a/AccessControl.py b/AccessControl.py index ba5a5d5..c262736 100644 --- a/AccessControl.py +++ b/AccessControl.py @@ -1,40 +1,68 @@ -from Config import * -import pickle, os -import datetime +"""Small file-backed login throttle. + +The legacy pickle file is deliberately not read: pickle is unsafe for mutable +runtime files. A JSON document is used instead and malformed files fail closed +to an empty recent-attempt list. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from Config import ACCESSFILE from Log import Log -class Access(): - access_list = [] - def __init__(self): - five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5) - new_list = [] - if os.path.exists(ACCESSFILE): - with open(ACCESSFILE, 'rb') as pf: - self.access_list = pickle.load(pf) - for entry in self.access_list: - if entry['time'] > five_minutes_ago: - new_list.append(entry) - self.access_list = new_list +class Access: + limit = 3 + window = timedelta(minutes=5) - def granted(self, ipaddress): - result = 0 - for entry in self.access_list: - if entry['ip'] == ipaddress: - result += 1 + def __init__(self, path: str = ACCESSFILE) -> None: + self.path = Path(path) + self.access_list = self._load() - if result<3: + def granted(self, ipaddress: str) -> bool: + failures = sum(entry["ip"] == ipaddress for entry in self.access_list) + if failures < self.limit: return True - - Log.info("Access denied for {}".format(ipaddress)) - self.deny(ipaddress) + Log.info(f"Access denied for {ipaddress}") return False + def deny(self, ipaddress: str) -> None: + self.access_list.append( + {"ip": str(ipaddress), "time": datetime.now(timezone.utc).isoformat()} + ) + self._save() - def deny(self, ipaddress): - now = datetime.datetime.now() - self.access_list.append({'ip': ipaddress, 'time': now}) - with open(ACCESSFILE, 'wb') as pf: - pickle.dump(self.access_list, pf, 2) - + def _load(self) -> list[dict[str, str]]: + cutoff = datetime.now(timezone.utc) - self.window + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + recent = [] + for entry in raw: + recorded = datetime.fromisoformat(entry["time"]) + if recorded.tzinfo is None: + recorded = recorded.replace(tzinfo=timezone.utc) + if recorded > cutoff: + recent.append({"ip": str(entry["ip"]), "time": recorded.isoformat()}) + return recent + except (OSError, ValueError, TypeError, KeyError): + return [] + def _save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + prefix=f".{self.path.name}.", dir=str(self.path.parent), text=True + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(self.access_list, handle, separators=(",", ":")) + os.chmod(temporary, 0o600) + os.replace(temporary, self.path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/Config.py b/Config.py index be4a097..65e976f 100644 --- a/Config.py +++ b/Config.py @@ -1,9 +1,62 @@ +"""Runtime configuration for Flowers. -LOGFILE = '/tmp/wsgi_flowers.log' -DEBUG = 0 -MAXTEXTLEN = 70 -MINFIELDLEN = 3 -INFO = 1 -DONOTSETFILTER = 'DoNotSetFilterinCookie' -ACCESSFILE = '/tmp/wsgi_flower_accessfile' -URLPREFIX = '' #'/flowers' +Secrets are read from the git-ignored ``secrets.yaml`` file. Environment +variables take precedence, which is useful for container deployments. +""" + +import os +import secrets +from pathlib import Path + +import yaml + + +BASE_DIR = Path(__file__).resolve().parent +SECRETS_FILE = Path( + os.getenv("FLOWERS_SECRETS_FILE", str(BASE_DIR / "secrets.yaml")) +) + + +def _load_secrets(path: Path) -> dict: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except FileNotFoundError: + return {} + except (OSError, yaml.YAMLError) as exc: + raise RuntimeError(f"Could not read Flowers secrets file: {path}") from exc + if not isinstance(data, dict): + raise RuntimeError(f"Flowers secrets file must contain a mapping: {path}") + return data + + +_SECRETS = _load_secrets(SECRETS_FILE) +_FLASK_SECRETS = _SECRETS.get("flask", {}) +_DATABASE_SECRETS = _SECRETS.get("database", {}) +if not isinstance(_FLASK_SECRETS, dict) or not isinstance(_DATABASE_SECRETS, dict): + raise RuntimeError("The flask and database secrets sections must be mappings") + + +LOGFILE = os.getenv("FLOWERS_LOG_FILE", "/tmp/wsgi_flowers.log") +DEBUG = os.getenv("FLOWERS_DEBUG", "0") == "1" +INFO = os.getenv("FLOWERS_INFO", "1") == "1" +MAXTEXTLEN = int(os.getenv("FLOWERS_MAX_TEXT_LENGTH", "70")) +MINFIELDLEN = int(os.getenv("FLOWERS_MIN_FIELD_LENGTH", "3")) +DONOTSETFILTER = "DoNotSetFilterinCookie" +ACCESSFILE = os.getenv("FLOWERS_ACCESS_FILE", "/tmp/wsgi_flower_accessfile") +URLPREFIX = os.getenv("FLOWERS_URL_PREFIX", "").rstrip("/") + +DB_HOST = os.getenv("FLOWERS_DB_HOST", "localhost") +DB_PORT = int(os.getenv("FLOWERS_DB_PORT", "3306")) +DB_NAME = os.getenv("FLOWERS_DB_NAME", "Flowers") +DB_ADMIN_USER = os.getenv("FLOWERS_DB_ADMIN_USER", "flower") +DB_ADMIN_PASSWORD = os.getenv( + "FLOWERS_DB_ADMIN_PASSWORD", str(_DATABASE_SECRETS.get("admin_password", "")) +) + +SESSION_MINUTES = int(os.getenv("FLOWERS_SESSION_MINUTES", "10")) +SECRET_KEY = ( + os.getenv("FLOWERS_SECRET_KEY") + or str(_FLASK_SECRETS.get("secret_key", "")) + or secrets.token_hex(32) +) +COOKIE_SECURE = os.getenv("FLOWERS_COOKIE_SECURE", "0") == "1" diff --git a/FlowerServices.py b/FlowerServices.py index 9e1a7d9..79b354d 100644 --- a/FlowerServices.py +++ b/FlowerServices.py @@ -1,269 +1,327 @@ -from fernet import Fernet -from Log import Log -from Config import * +"""Database and encryption services for the legacy Flowers data format. + +The public ``Flower`` API intentionally remains compatible with the original +application. Existing tables and Fernet ciphertext require no migration. +""" + +from __future__ import annotations + +import re +from contextlib import contextmanager +from typing import Callable, Iterator + import pymysql as mdb +from fernet import Fernet -#encryption stuff -SECRET = b'XBxB603cX_mULEXxfavOg3FDc0Ox3gChwYEY-Uxd3tE=' +from Config import ( + DB_ADMIN_PASSWORD, + DB_ADMIN_USER, + DB_HOST, + DB_NAME, + DB_PORT, + MINFIELDLEN, +) +from Log import Log -class Flower(): +_USERNAME = re.compile(r"^[A-Za-z0-9_]{1,31}$") - def __init__(self, user, pwd): - self.db = 'Flowers' - self.db_username = user + '_' - self.db_password = (pwd*10)[:43]+"=" + +class InvalidCredentials(ValueError): + """The supplied username/password cannot address a Flowers vault.""" + + +class StorageError(RuntimeError): + """A database operation failed.""" + + +def legacy_key(password: str) -> str: + """Return the exact DB password/Fernet key used by historical releases.""" + if not isinstance(password, str) or not password: + raise InvalidCredentials("A password is required") + key = (password * 10)[:43] + "=" + try: + Fernet(key.encode("ascii")) + except (UnicodeEncodeError, ValueError) as exc: + raise InvalidCredentials( + "Password must use Base64-compatible characters" + ) from exc + return key + + +def _identifier(username: str) -> str: + if not isinstance(username, str) or not _USERNAME.fullmatch(username): + raise InvalidCredentials( + "Username must contain only letters, numbers, and underscores" + ) + return f"{username}_" + + +class Flower: + """Read and write one user's existing Flowers table.""" + + def __init__( + self, + user: str, + pwd: str, + connection_factory: Callable[..., object] | None = None, + ) -> None: + self.db = DB_NAME + self.db_username = _identifier(user) + self.db_password = legacy_key(pwd) self.customer_table = self.db_username - self.fernet = Fernet(bytes(self.db_password, 'ascii')) + self._table = f"`{self.customer_table}`" + self.fernet = Fernet(self.db_password.encode("ascii")) + self._connect = connection_factory or mdb.connect - def encode(self, s): - if len(s)==0: - return '' - return self.fernet.encrypt(s.encode()).decode() - - def decode(self, s): - if len(s)==0: - return '' - return self.fernet.decrypt(s.encode()).decode() - - def add(self, organization, myname, myid, secret, deleted=0, timestamp=None): - ''' POST a new flower - ''' - if len(organization)>MINFIELDLEN and len(myid)>MINFIELDLEN and len(secret)>MINFIELDLEN: - sql = 'INSERT INTO {} (organization ,myID ,myName ,mySecret, deleted) VALUES ("{}", "{}", "{}", "{}", {})'.format(self.customer_table, organization, self.encode(myid), self.encode(myname), self.encode(secret), deleted) - if timestamp is not None: - sql = 'INSERT INTO {} (organization ,myID ,myName ,mySecret, deleted, dateCreated) VALUES ("{}", "{}", "{}", "{}", {}, "{}")'.format(self.customer_table, organization, self.encode(myid), self.encode(myname), self.encode(secret), deleted, timestamp) - result, data = self.do_db_commit(sql) - Log.debug(result) - if result==1: - return {'organization': organization, 'dateCreated': 'STORED', 'myID': myid, 'myName': myname,'mySecret': secret} - return {'organization': organization, 'dateCreated': 'FAILED', 'myID': myid, 'myName': myname,'mySecret': secret} - - - def all(self, isdeleted=0): - ''' GET flowers list - ''' - #sql = "SELECT organization, myID, myName, dateCreated FROM %s " % (self.customer_table) - sql = """ - select uniqueflowers.organization, uniqueflowers.myID, uniqueflowers.dateCreated from {0} as uniqueflowers - inner join ( - select organization, max(dateCreated) as lastCreated from {0} group by organization order by dateCreated DESC) allflowers - on allflowers.organization=uniqueflowers.organization and lastCreated=uniqueflowers.dateCreated where uniqueflowers.deleted={1} - """.format(self.customer_table, isdeleted) - result, data = self.do_db_allrows(sql) - #Log.debug(sql_result) - all_flowers=[] - if result==1: - for row in data: - all_flowers.append({'organization': str(row[0]), 'myID': self.decode(row[1]), 'dateCreated': str(row[2])}) - return all_flowers - - - - def one(self, organization, datetimestamp ): - ''' flower for user, org and datetime - ''' - result = -1 - secret = ':-(' - sql = "SELECT mySecret, myID, myName FROM %s where organization='%s' and dateCreated='%s' order by dateCreated DESC limit 1" % (self.customer_table, organization, datetimestamp) - result, data = self.do_db_1row(sql) - if result == 1: - return {'organization': organization, 'dateCreated': datetimestamp, 'myID': self.decode(data[1]), 'myName': self.decode(data[2]),'mySecret': self.decode(data[0])} - return {'organization': organization, 'dateCreated': datetimestamp, 'myID': '', 'myName': '','mySecret': '-- Not found --'} - - - def deactivate(self, organization, datetimestamp ): - ''' flower for user, org and datetime - ''' - result = -1 - secret = ':-(' - sql = "update {} set deleted=1 where organization='{}' and dateCreated='{}' ".format(self.customer_table, organization, datetimestamp) - result, data = self.do_db_commit(sql) - if result == 1: - secret = '- DELETED -' - return {'organization': organization, 'dateCreated': datetimestamp, 'myID': '', 'myName': '','mySecret': secret} - - def empty(self): - ''' empty flower - ''' - return {'organization': '', 'dateCreated': '', 'myID': '', 'myName': '','mySecret': ''} - - - def numberOfEntries(self): - ''' GET login info, check if its valid - ''' - sql = "SELECT count(*) FROM {};".format(self.customer_table) - result, data = self.do_db_1row(sql) - if result == 1: - return(data[0]) - return -1 - - - def update_pwd(self, new_pwd): - - long_newpwd = (new_pwd*10)[:43]+"=" - self.new_fernet = Fernet(bytes(long_newpwd, 'ascii')) - - def new_encode(s): - if len(s)==0: - return '' - return self.new_fernet.encrypt(s.encode()).decode() - - end_result = "ERROR updating hushes\n" - sql = """ - select myID, myName, mySecret, organization, dateCreated from {0}""".format(self.customer_table) - result, data = self.do_db_allrows(sql) - # Log.debug(sql_result) - if result == 1: - # convert all hushes - sql = "" - for row in data: - sql += "update {} set myID='{}', myName='{}', mySecret='{}' where organization='{}' and dateCreated='{}';\n".\ - format(self.customer_table, - new_encode(self.decode(row[0])), - new_encode(self.decode(row[1])), - new_encode(self.decode(row[2])),row[3], row[4]) - - result, data = self.do_db_commit(sql) - if result == 1: - end_result = "ERROR updating user-password\n" - # update the password of the user - sql = "SET PASSWORD FOR '{}'@'localhost' = PASSWORD('{}'); ".format(self.db_username, long_newpwd) - result, data = self.do_db_commit(sql) - if result == 1: - end_result = "SUCCESSFULLY updated, now login again\n" - return end_result - - - def do_db_commit(self, sql): - Log.debug('INSc: '+sql) - result = 0 - data = '' - con = None + @contextmanager + def _connection(self) -> Iterator[object]: + connection = None try: - con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db); - cur = con.cursor() - if ";\n" in sql: - for s in sql.split(";\n"): - if len(s)>1: - cur.execute(s) - else: - cur.execute(sql) - cur.close() - con.commit() - result = 1 - except mdb.Error as e: - m = "Error %d: %s" % (e.args[0],e.args[1]) - data = {'message': m} - Log.debug(m) - result = -1 + connection = self._connect( + host=DB_HOST, + port=DB_PORT, + user=self.db_username, + passwd=self.db_password, + db=self.db, + charset="latin1", + ) + yield connection + except mdb.Error as exc: + Log.debug(f"Database error: {exc}") + raise StorageError("The vault database is unavailable") from exc finally: - if con: - con.close() - return result, data + if connection is not None: + connection.close() + def encode(self, value: str) -> str: + if not value: + return "" + return self.fernet.encrypt(value.encode("utf-8")).decode("ascii") - def do_db_1row(self, sql): - Log.debug('1row sql: '+sql) - result = 0 - con = None + def decode(self, value: str) -> str: + if not value: + return "" + return self.fernet.decrypt(value.encode("ascii")).decode("utf-8") + + def authenticate(self) -> bool: try: - con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db); - cur = con.cursor() - cur.execute(sql) - data = cur.fetchone() - if data: - result = 1 - except mdb.Error as e: - m = "Error %d: %s" % (e.args[0],e.args[1]) - data = {'message': m} - Log.debug(m) - result = -1 - finally: - if con: - con.close() - return result, data + with self._connection() as connection: + with connection.cursor() as cursor: + cursor.execute(f"SELECT COUNT(*) FROM {self._table}") + cursor.fetchone() + return True + except (StorageError, ValueError): + return False - def do_db_allrows(self, sql): - Log.debug('all rows sql: '+sql) - result = 0 - con = None + def numberOfEntries(self) -> int: + """Compatibility method used by older clients and scripts.""" try: - con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db); - cur = con.cursor() - cur.execute(sql) - data = cur.fetchall() - if data: - result = 1 - except mdb.Error as e: - m = "Error %d: %s" % (e.args[0],e.args[1]) - data = {'message': m} - Log.debug(m) - result = -1 - finally: - if con: - con.close() - return result, data + with self._connection() as connection: + with connection.cursor() as cursor: + cursor.execute(f"SELECT COUNT(*) FROM {self._table}") + row = cursor.fetchone() + return int(row[0]) if row else 0 + except StorageError: + return -1 + + def add( + self, + organization: str, + myname: str, + myid: str, + secret: str, + deleted: int = 0, + timestamp=None, + ) -> dict: + item = self._item(organization, "FAILED", myid, myname, secret) + if not all(isinstance(v, str) for v in (organization, myname, myid, secret)): + return item + if not ( + len(organization) > MINFIELDLEN + and len(myid) > MINFIELDLEN + and len(secret) > MINFIELDLEN + ): + return item + + columns = "organization, myID, myName, mySecret, deleted" + values = [ + organization, + self.encode(myid), + self.encode(myname), + self.encode(secret), + int(bool(deleted)), + ] + placeholders = "%s, %s, %s, %s, %s" + if timestamp is not None: + columns += ", dateCreated" + placeholders += ", %s" + values.append(timestamp) + + sql = f"INSERT INTO {self._table} ({columns}) VALUES ({placeholders})" + self._execute_write(sql, values) + return self._item(organization, "STORED", myid, myname, secret) + + def all(self, isdeleted: int = 0) -> list[dict]: + sql = f""" + SELECT latest.organization, latest.myID, latest.dateCreated + FROM {self._table} AS latest + INNER JOIN ( + SELECT organization, MAX(dateCreated) AS lastCreated + FROM {self._table} + GROUP BY organization + ) AS versions + ON versions.organization = latest.organization + AND versions.lastCreated = latest.dateCreated + WHERE latest.deleted = %s + ORDER BY latest.dateCreated DESC, latest.organization ASC + """ + with self._connection() as connection: + with connection.cursor() as cursor: + cursor.execute(sql, (int(bool(isdeleted)),)) + rows = cursor.fetchall() + return [ + { + "organization": str(row[0]), + "myID": self.decode(row[1]), + "dateCreated": str(row[2]), + } + for row in rows + ] + + def one(self, organization: str, datetimestamp) -> dict: + sql = f""" + SELECT mySecret, myID, myName + FROM {self._table} + WHERE organization = %s AND dateCreated = %s + ORDER BY dateCreated DESC + LIMIT 1 + """ + with self._connection() as connection: + with connection.cursor() as cursor: + cursor.execute(sql, (organization, datetimestamp)) + row = cursor.fetchone() + if row: + return self._item( + organization, + str(datetimestamp), + self.decode(row[1]), + self.decode(row[2]), + self.decode(row[0]), + ) + return self._item(organization, str(datetimestamp), "", "", "-- Not found --") + + def deactivate(self, organization: str, datetimestamp) -> dict: + sql = f"UPDATE {self._table} SET deleted = 1 WHERE organization = %s AND dateCreated = %s" + changed = self._execute_write(sql, (organization, datetimestamp)) + message = "- DELETED -" if changed else "-- Not found --" + return self._item(organization, str(datetimestamp), "", "", message) + + def empty(self) -> dict: + return self._item("", "", "", "", "") + + def update_pwd(self, new_pwd: str) -> str: + """Re-encrypt every value and update the legacy per-user DB password.""" + new_db_password = legacy_key(new_pwd) + new_fernet = Fernet(new_db_password.encode("ascii")) + + try: + with self._connection() as connection: + try: + with connection.cursor() as cursor: + cursor.execute( + f"SELECT myID, myName, mySecret, organization, dateCreated FROM {self._table}" + ) + rows = cursor.fetchall() + update = f""" + UPDATE {self._table} + SET myID = %s, myName = %s, mySecret = %s + WHERE organization = %s AND dateCreated = %s + """ + for row in rows: + encrypted = [ + new_fernet.encrypt(self.decode(value).encode("utf-8")).decode("ascii") + if value else "" + for value in row[:3] + ] + cursor.execute(update, (*encrypted, row[3], row[4])) + # This form is supported by the MariaDB versions used by + # historical Flowers installations. + cursor.execute("SET PASSWORD = PASSWORD(%s)", (new_db_password,)) + connection.commit() + except Exception: + connection.rollback() + raise + except Exception as exc: + Log.debug(f"Password update failed: {exc}") + return "ERROR updating password" + return "SUCCESSFULLY updated, now login again" + + def _execute_write(self, sql: str, parameters) -> int: + with self._connection() as connection: + try: + with connection.cursor() as cursor: + changed = cursor.execute(sql, parameters) + connection.commit() + return int(changed) + except Exception: + connection.rollback() + raise + + @staticmethod + def _item(organization, timestamp, myid, myname, secret) -> dict: + return { + "organization": organization, + "dateCreated": timestamp, + "myID": myid, + "myName": myname, + "mySecret": secret, + } class SuperFlower(Flower): + """Provision a legacy per-user table and database account.""" - def __init__(self, customer, pwd): - super().__init__(customer, pwd) - self.customer_name = customer + '_' - self.customer_pwd = (pwd*10)[:43]+"=" + def __init__(self, customer: str, pwd: str, connection_factory=None) -> None: + super().__init__(customer, pwd, connection_factory) + self.customer_name = self.customer_table + self.customer_pwd = self.db_password + self.db_username = DB_ADMIN_USER + self.db_password = DB_ADMIN_PASSWORD - self.db_username = 'flower' - self.db_password = '608f0b988db4a96066af7dd8870de96c' - - def createNewTable(self): - # create new table and user - sql = """ - CREATE TABLE `{}` ( - `organization` varchar(80) NOT NULL, - `myID` varchar(255) NOT NULL, - `myName` varchar(255) NOT NULL, - `mySecret` varchar(255) NOT NULL, - `dateCreated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `deleted` int(11) DEFAULT '0', - PRIMARY KEY (`organization`,`dateCreated`) - ) ENGINE=InnoDB DEFAULT CHARSET=latin1;""".format(self.customer_table) - result, data = self.do_db_1row(sql) - if result < 0: - Log.info("SQL ERROR:") - Log.info(data) + def createNewTable(self) -> int: + statements = [ + f"""CREATE TABLE {self._table} ( + organization varchar(80) NOT NULL, + myID varchar(255) NOT NULL, + myName varchar(255) NOT NULL, + mySecret varchar(255) NOT NULL, + dateCreated timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted int(11) DEFAULT 0, + PRIMARY KEY (organization, dateCreated) + ) ENGINE=InnoDB DEFAULT CHARSET=latin1""", + f"CREATE USER '{self.customer_name}'@'localhost' IDENTIFIED BY %s", + f"GRANT SELECT, UPDATE, INSERT ON `{self.db}`.{self._table} TO '{self.customer_name}'@'localhost'", + ] + try: + with self._connection() as connection: + with connection.cursor() as cursor: + cursor.execute(statements[0]) + cursor.execute(statements[1], (self.customer_pwd,)) + cursor.execute(statements[2]) + connection.commit() + return 1 + except StorageError as exc: + Log.info(f"Could not create vault: {exc}") return 0 - sql = """create user {}@localhost identified by '{}';""".format(self.customer_name, self.customer_pwd) - result, data = self.do_db_1row(sql) - if result < 0: - Log.info("SQL ERROR:") - Log.info(data) + def flush_privs(self) -> int: + try: + self._execute_write("FLUSH PRIVILEGES", ()) + return 1 + except StorageError: return 0 - sql = """grant select,update,insert on Flowers.{} to {}@localhost;""".format(self.customer_table, self.customer_name) - result, data = self.do_db_1row(sql) - if result < 0: - Log.info("SQL ERROR:") - Log.info(data) - return 0 - self.flush_privs() - - return 1 - - def flush_privs(self): - sql = """flush privileges;""" - result, data = self.do_db_1row(sql) - if result < 0: - Log.info("SQL ERROR:") - Log.info(data) - return 0 - return 1 - - - - - -if __name__ == '__main__': - print("this is a library, sorry dude") +if __name__ == "__main__": + print("This module is a library.") diff --git a/Log.py b/Log.py index 7ef5550..425e017 100644 --- a/Log.py +++ b/Log.py @@ -1,20 +1,27 @@ -from sys import stderr -from Config import * +"""Compatibility logging facade.""" + +import logging +from logging.handlers import RotatingFileHandler + +from Config import DEBUG, INFO, LOGFILE + + +_logger = logging.getLogger("flowers") +if not _logger.handlers and (DEBUG or INFO): + try: + handler = RotatingFileHandler(LOGFILE, maxBytes=1_000_000, backupCount=2) + handler.setFormatter(logging.Formatter("%(asctime)s flowers %(levelname)s %(message)s")) + _logger.addHandler(handler) + _logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) + except OSError: + _logger.addHandler(logging.NullHandler()) + class Log: @staticmethod - def info(someString): - if INFO: - f = open(LOGFILE, "a") - f.write( "flower: %s \n" % someString ) - f.close() - if DEBUG: - print(someString) + def info(message) -> None: + _logger.info("%s", message) @staticmethod - def debug(someString): - if DEBUG: - f = open(LOGFILE, "a") - f.write( "flower: %s \n" % someString ) - f.close() - + def debug(message) -> None: + _logger.debug("%s", message) diff --git a/README.md b/README.md index 4e5d068..1e8f357 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,142 @@ # Flowers -Flowers is a small, self-hosted credential vault built with Flask and MariaDB/MySQL. It stores a label, login name, login ID, and secret for each entry, and provides both a server-rendered browser interface and a form-encoded JSON endpoint. +Flowers is a small self-hosted credential vault built with Flask and MariaDB/MySQL. This version rewrites the legacy server and browser UI while intentionally preserving its on-disk data contract. -> [!WARNING] -> This is legacy, personal-use software and is **not production-ready in its current form**. The repository contains hard-coded application/database secrets and committed data exports (`db.sql` and `pins.csv`). The application also builds SQL with string interpolation, has no CSRF protection, and relies on an unusual password-derived encryption scheme. Treat every committed dump as sensitive, rotate any real credentials it contains, and review the [security notes](#security-notes) before exposing the service to a network. +## Existing database compatibility -## What it does +No data migration is required. The rewritten application continues to use: -- Creates a separate database user and table for each Flowers user. -- Encrypts the login name, login ID, and secret with Fernet before storing them. -- Lists the most recent active record for each organization. -- Supports searching, viewing, adding, and soft-deleting records. -- Keeps older records when a new version of an organization is added. -- Locks an IP address out after three failed logins within five minutes. -- Exposes the same core operations through `POST /app` for a companion client. +- the `Flowers` database; +- one database account and table named `_` per vault; +- the existing `organization`, `myID`, `myName`, `mySecret`, `dateCreated`, and `deleted` columns; +- the historical password transformation `(password * 10)[:43] + "="` for both the database password and Fernet key; +- Fernet ciphertext in the three protected columns; +- append-only edits, where the newest timestamp is the visible version; and +- `deleted = 1` for deactivation. -The included `client/Daisy.py` is only a Kivy “Hello world” scaffold; it is not a working Flowers client. +The old form-encoded `POST /app` endpoint is also retained. Its `list` and `flower` properties remain JSON strings inside the outer JSON response for existing client compatibility. -## How the application is organized +The legacy key derivation is preserved only because changing it would make existing data unreadable. It is not a modern password KDF. A future KDF upgrade requires an explicit, tested data migration rather than an in-place code change. -```text -flowers.py Flask application factory -flower_vase.py Browser routes and the form-based JSON endpoint -FlowerServices.py Fernet encryption and MariaDB/MySQL operations -AccessControl.py File-backed failed-login throttling -Config.py Paths, limits, logging, and URL prefix -templates/ Jinja browser interface -static/ CSS, JavaScript, fonts, and images -flowers.wsgi Legacy mod_wsgi entry point (currently stale) -apache.conf Legacy Apache/mod_wsgi example -db.sql, pins.csv Historical data exports; sensitive, not seed data -Services.py, -SecretServices.py Older AES-based implementations retained for migration -``` +## What changed -### Data model - -Each application username maps to: - -- a database account named `_`; -- a table named `_` in the `Flowers` database; and -- a database password/Fernet key derived by repeating the application password and truncating it to 43 characters, then appending `=`. - -Each table contains: - -| Column | Purpose | -| --- | --- | -| `organization` | Plain-text label used to group versions of an entry | -| `myID` | Fernet-encrypted login ID | -| `myName` | Fernet-encrypted display/login name | -| `mySecret` | Fernet-encrypted secret | -| `dateCreated` | Creation time and version identifier | -| `deleted` | Soft-delete flag | - -The organization and timestamp are not encrypted. “Editing” an item inserts a new row, so the latest timestamp becomes the visible version. Deactivation marks a selected row as deleted. +- All stored values are passed to MariaDB as query parameters. +- Usernames are validated before being used as table/account identifiers. +- Database and runtime settings can be supplied through environment variables. +- Browser forms have CSRF protection. +- Database credentials are stored in server memory behind an opaque session token, not in Flask's signed browser cookie. +- Browser sessions expire after ten minutes of inactivity. +- Failed-login state is JSON rather than unsafe pickle data. +- The browser interface is responsive and no longer depends on jQuery, Pure CSS, or remote assets. +- The Flask application factory and WSGI entry point both work. +- Compatibility tests cover legacy encryption, reads, writes, listing, and the old API format. ## Requirements -- Python 3 -- MariaDB or MySQL running on `localhost` -- Python packages: Flask, Waitress, `fernet`, PyMySQL, and pytz -- Apache with `mod_proxy` only if using the optional reverse-proxy setup - -No dependency lock file is included, so the project does not currently define tested package or Python version ranges. - -## Local setup - -### 1. Create a virtual environment +- Python 3.10 or newer +- MariaDB or MySQL on the configured host +- the packages in `requirements.txt` ```bash python3 -m venv .venv source .venv/bin/activate -pip install flask waitress fernet pymysql pytz +pip install -r requirements.txt ``` -The code imports `Fernet` from the third-party `fernet` package, not from `cryptography.fernet`. +## Configuration -### 2. Bootstrap MariaDB/MySQL +Local secrets are loaded from the git-ignored `secrets.yaml` file: -The application expects a database named `Flowers` and a provisioning account named `flower`. With the repository's current defaults, an administrator can create them with: +```yaml +flask: + secret_key: "a-long-random-value" + +database: + admin_password: "the-provisioning-account-password" +``` + +Use `secrets.example.yaml` as a template. `FLOWERS_SECRETS_FILE` can point to a different file. Secret environment variables take precedence over YAML, which is useful for containers and managed deployments. + +The other settings remain environment-based: + +| Environment variable | Default | +| --- | --- | +| `FLOWERS_SECRETS_FILE` | `secrets.yaml` beside the application | +| `FLOWERS_SECRET_KEY` | overrides `flask.secret_key` from YAML | +| `FLOWERS_DB_HOST` | `localhost` | +| `FLOWERS_DB_PORT` | `3306` | +| `FLOWERS_DB_NAME` | `Flowers` | +| `FLOWERS_DB_ADMIN_USER` | `flower` | +| `FLOWERS_DB_ADMIN_PASSWORD` | overrides `database.admin_password` from YAML | +| `FLOWERS_URL_PREFIX` | empty | +| `FLOWERS_SESSION_MINUTES` | `10` | +| `FLOWERS_COOKIE_SECURE` | `0`; set to `1` behind HTTPS | +| `FLOWERS_ACCESS_FILE` | `/tmp/wsgi_flower_accessfile` | +| `FLOWERS_LOG_FILE` | `/tmp/wsgi_flowers.log` | + +For example: + +```bash +waitress-serve --threads=6 --host=127.0.0.1 --port=5012 --call flowers:create_app +``` + +The provisioning account is needed only by the web-based `/create` flow. Existing vault reads and writes connect with their existing per-user database accounts. + +## Database bootstrap for a new installation + +An existing Flowers database should be left untouched. For a new installation, create the database and provisioning user, replacing the example password: ```sql CREATE DATABASE Flowers; -CREATE USER 'flower'@'localhost' - IDENTIFIED BY '608f0b988db4a96066af7dd8870de96c'; +CREATE USER 'flower'@'localhost' IDENTIFIED BY 'replace-this'; GRANT CREATE, SELECT, UPDATE, INSERT ON Flowers.* TO 'flower'@'localhost' WITH GRANT OPTION; GRANT CREATE USER, RELOAD ON *.* TO 'flower'@'localhost'; FLUSH PRIVILEGES; ``` -That password is hard-coded in `SuperFlower` in `FlowerServices.py`. Change it in both the database and the code before real use. Do **not** import `db.sql` as ordinary sample data: it is a historical dump containing user-specific encrypted records. +Put the same value in `database.admin_password` in `secrets.yaml` before starting Flowers. -### 3. Select the URL prefix +## Run -Set `URLPREFIX` in `Config.py`: - -```python -URLPREFIX = '' # serve at http://127.0.0.1:5012/ -# URLPREFIX = '/flowers' # serve below /flowers -``` - -### 4. Start the server - -For local development: +Development: ```bash python flowers.py ``` -For a non-debug application server: +Production application server: ```bash waitress-serve --threads=6 --host=127.0.0.1 --port=5012 --call flowers:create_app ``` -Open the configured base URL in a browser. To create the first vault user, visit `/create` under that base URL—for example, `http://127.0.0.1:5012/create` when `URLPREFIX` is empty. After creation, return to the home page and log in. +Keep the service behind HTTPS. If it is mounted under `/flowers`, set `FLOWERS_URL_PREFIX=/flowers` before starting it. -The creation form limits passwords to 10 characters. Because the password is also transformed directly into a Fernet key, use only characters valid in URL-safe Base64 (`A-Z`, `a-z`, `0-9`, `-`, and `_`) with the current implementation. +## Tests -## Browser routes - -All routes are relative to `URLPREFIX`. - -| Route | Methods | Purpose | -| --- | --- | --- | -| `/` | GET | Login page | -| `/browser` | GET, POST | Login and browser actions (`list`, `show`, `new`, `save`, `deactivate`, `logout`) | -| `/create` | GET, POST | Create a database user, table, and initial demo entry | -| `/update_pwd` | GET, POST | Re-encrypt all records and change the database password | -| `/app` | POST | Form-encoded programmatic interface returning JSON text | - -Browser sessions expire after 10 minutes of inactivity. Failed logins are recorded in `ACCESSFILE`; three failures from one IP within five minutes cause temporary denial. - -## Programmatic endpoint - -`POST /app` accepts `application/x-www-form-urlencoded` fields. Every request includes: - -- `action`: `login`, `list`, `one`, `save`, or `deactivate`; -- `name`: Flowers username; and -- `password`: Flowers password. - -Additional fields depend on the action: - -| Action | Additional fields | -| --- | --- | -| `list` | none | -| `one` | `organization`, `dateCreated` | -| `save` | `organization`, `myname`, `myid`, `secret` | -| `deactivate` | `organization`, `dateCreated` | - -Example login request: +The tests do not need a live database; they use the actual legacy Fernet implementation with a MariaDB-compatible fake connection. ```bash -curl -X POST http://127.0.0.1:5012/app \ - --data-urlencode 'action=login' \ - --data-urlencode 'name=demo' \ - --data-urlencode 'password=replace-me' +pip install -r requirements-dev.txt +python -m pytest ``` -A typical response is: - -```json -{"result": 1, "message": "Access granted"} -``` - -For historical compatibility, the `list` and `flower` properties are JSON-encoded strings inside the outer JSON response, so clients must decode those properties a second time. The endpoint catches all exceptions and may return only the generic `{"result": -1, "message": "Invalid entry"}` response when an internal error occurs. - -## Apache reverse proxy - -Waitress can remain bound to localhost while Apache publishes the `/flowers` path. Set `URLPREFIX = '/flowers'`, enable the proxy modules, and add the proxy rules to the relevant virtual host: +They can also run using only the standard library test runner: ```bash -sudo a2enmod proxy proxy_http +python -m unittest discover -s tests -v ``` -```apache -ProxyPass "/flowers" "http://127.0.0.1:5012/flowers" -ProxyPassReverse "/flowers" "http://127.0.0.1:5012/flowers" -``` +## Remaining security constraints -Then run Waitress with the command shown above and configure HTTPS on Apache. The checked-in `apache.conf` and `flowers.wsgi` describe an older mod_wsgi deployment; `flowers.wsgi` imports an `app` object that no longer exists, so use the application factory or update that file before relying on mod_wsgi. +The rewrite removes the immediately exploitable SQL interpolation and browser-cookie credential storage, but Flowers remains a small personal vault with a legacy storage design: -## Configuration +- the historical password-derived Fernet key is intentionally still weak; +- the web process still has provisioning privileges if `/create` is enabled; +- the in-memory credential store is suitable for one application process, not a multi-process cluster; and +- rate-limit state is local to one host. -`Config.py` contains the available settings: +Use a strong unique password made only of Base64-compatible characters, bind Waitress to localhost, and expose it only through an authenticated HTTPS reverse proxy. Set stable provisioning and Flask secrets before deployment. -| Setting | Default purpose | -| --- | --- | -| `LOGFILE` | Application log path (`/tmp/wsgi_flowers.log`) | -| `DEBUG` | Enables debug messages and debug logging when non-zero | -| `INFO` | Enables informational file logging when non-zero | -| `MAXTEXTLEN` | Intended maximum text length used by the UI/domain code | -| `MINFIELDLEN` | Minimum accepted length for organization, login ID, and secret | -| `ACCESSFILE` | Pickle file used for failed-login throttling | -| `DONOTSETFILTER` | Sentinel used by the browser search/filter form | -| `URLPREFIX` | Blueprint prefix, such as `''` or `'/flowers'` | - -The Flask session signing key is currently hard-coded in `flowers.py`. Configuration is not loaded from environment variables. - -## Tests and maintenance scripts - -There is currently no automated test suite. A syntax-only check for the primary modules can be run without a database: - -```bash -python -m py_compile \ - flowers.py flower_vase.py FlowerServices.py \ - AccessControl.py Config.py Log.py -``` - -The files `test.py`, `test_fernet.py`, `import.py`, and `dumpl.py` are ad-hoc scripts with embedded usernames/passwords or destructive/database-dependent behavior. Read and edit them before running them; they are not safe, isolated unit tests. - -## Security notes - -Before any serious deployment, at minimum: - -2. Move the Flask secret key, provisioning database password, database name/host, and filesystem paths to environment-based configuration. -3. Replace string-formatted SQL with parameterized queries and validate table/account identifiers. -4. Replace the current password-to-Fernet-key construction with a password KDF such as Argon2id, scrypt, or PBKDF2 using a unique salt and appropriate work factor. -5. Add CSRF protection, secure cookie settings, explicit error handling, and tests for authentication and authorization. -6. Run only behind HTTPS and restrict access at the firewall or reverse proxy. -7. Rework user provisioning so the web process does not hold `CREATE USER`, `RELOAD`, and grant privileges during normal operation. -8. Replace the pickle-backed access-control file with a safe, concurrency-aware rate limiter. - -Fernet protects stored field contents from casual database inspection, but it does not compensate for weak/reused passwords, exposed keys, SQL injection, a compromised application host, or unencrypted transport. - -## License - -No license file is currently included. Unless a license is added, normal copyright restrictions apply. +The old `Services.py`, `SecretServices.py`, and ad-hoc root-level scripts are retained as historical migration references. Do not run them against production data without reviewing them first. diff --git a/flower_vase.py b/flower_vase.py index 40c3a7b..907509e 100644 --- a/flower_vase.py +++ b/flower_vase.py @@ -1,202 +1,328 @@ -from flask import Flask, Blueprint, send_from_directory -from Config import * -import sys -import datetime -import pytz -from FlowerServices import Flower,SuperFlower -from AccessControl import Access +"""HTTP routes for the Flowers browser UI and legacy form API.""" + +from __future__ import annotations + import json -from flask import Flask, request, session, g, redirect, url_for, abort, render_template, flash, json, make_response -from werkzeug.exceptions import HTTPException +import secrets +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone -# configuration -# DEBUG = False -# SECRET_KEY = 'development key' -flower_bp = Blueprint("flower_vase", __name__, url_prefix=URLPREFIX) +from flask import ( + Blueprint, + abort, + current_app, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) + +from AccessControl import Access +from Config import DONOTSETFILTER, SESSION_MINUTES, URLPREFIX +from FlowerServices import Flower, InvalidCredentials, StorageError, SuperFlower -# app = Flask(__name__) -# app.config.from_object(__name__) +flower_bp = Blueprint( + "flower_vase", + __name__, + url_prefix=URLPREFIX, + static_folder="static", + static_url_path="/static", + template_folder="templates", +) -@flower_bp.route('/static/') -def statix(filename): - return send_from_directory('static', filename) -@flower_bp.route('/') +@dataclass +class _Credential: + username: str + password: str + expires_at: datetime + + +class CredentialStore: + """Keep database credentials server-side instead of in Flask's cookie.""" + + def __init__(self) -> None: + self._items: dict[str, _Credential] = {} + self._lock = threading.Lock() + + def create(self, username: str, password: str) -> str: + token = secrets.token_urlsafe(32) + with self._lock: + self._purge() + self._items[token] = _Credential(username, password, self._deadline()) + return token + + def get(self, token: str | None) -> tuple[str, str] | None: + if not token: + return None + with self._lock: + self._purge() + credential = self._items.get(token) + if credential is None: + return None + credential.expires_at = self._deadline() + return credential.username, credential.password + + def revoke(self, token: str | None) -> None: + if token: + with self._lock: + self._items.pop(token, None) + + def _purge(self) -> None: + now = datetime.now(timezone.utc) + expired = [key for key, value in self._items.items() if value.expires_at <= now] + for key in expired: + del self._items[key] + + @staticmethod + def _deadline() -> datetime: + return datetime.now(timezone.utc) + timedelta(minutes=SESSION_MINUTES) + + +def _credentials() -> CredentialStore: + return current_app.extensions["credential_store"] + + +def _csrf_token() -> str: + token = session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["csrf_token"] = token + return token + + +@flower_bp.context_processor +def template_context() -> dict: + return {"csrf_token": _csrf_token()} + + +@flower_bp.before_request +def protect_browser_posts(): + if request.method != "POST" or request.endpoint == "flower_vase.web_service": + return None + supplied = request.form.get("csrf_token", "") + expected = session.get("csrf_token", "") + if not expected or not secrets.compare_digest(supplied, expected): + abort(400, description="Invalid or expired form token") + return None + + +def _vault_from_session() -> Flower | None: + credential = _credentials().get(session.get("auth_token")) + if not credential: + return None + try: + return Flower(*credential) + except InvalidCredentials: + return None + + +def _login_page(name="Welcome back", password="Enter your vault password", status=200): + return render_template( + "index.html", name_suggestion=name, pwd_suggestion=password + ), status + + +@flower_bp.get("/") def index(): - print(request.remote_addr) - return render_template('index.html', name_suggestion="Hello", pwd_suggestion="Want to try your luck?") + return _login_page() -@flower_bp.route('/browser' , methods=['POST', 'GET']) + +@flower_bp.route("/browser", methods=["GET", "POST"]) def application(): - now = datetime.datetime.now(pytz.timezone('Europe/Amsterdam')) + if request.method == "GET": + vault = _vault_from_session() + if vault is None: + return redirect(url_for("flower_vase.index")) + try: + selected_filter = session.get("filter", "") + return render_template( + "list.html", flowers=vault.all(), filter=selected_filter + ) + except StorageError: + flash("The vault database could not complete that request.", "error") + return redirect(url_for("flower_vase.index")) - if request.method == 'GET': - return render_template('index.html', name_suggestion="Faded out", pwd_suggestion="Want to retry your luck?") + access = Access() + client_ip = request.remote_addr or "unknown" + if not access.granted(client_ip): + return _login_page("Too many attempts", "Try again in five minutes", 429) - access_ctrl = Access() - if not access_ctrl.granted(request.remote_addr): - return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Access denied") + action = request.form.get("action", "") + if action == "login": + return _login(access, client_ip) + if action == "logout": + _credentials().revoke(session.pop("auth_token", None)) + session.pop("filter", None) + flash("You have been signed out.", "success") + return redirect(url_for("flower_vase.index")) - action=request.form['action'] - filter='' - if 'filter' in session: - filter = session['filter'] + vault = _vault_from_session() + if vault is None: + session.pop("auth_token", None) + flash("Your session expired. Please sign in again.", "error") + return redirect(url_for("flower_vase.index")) - if action == 'logout': - session['filter'] = request.form['filter'] - session['timeout'] = now - datetime.timedelta(minutes=999) - return render_template('index.html', name_suggestion="Bye", pwd_suggestion="Come back any time") - - - if action == 'login': - user=request.form['name'] - pwd=request.form['password'] - F = Flower(user, pwd) - if F.numberOfEntries()>=0: - session['dbuser'] = user - session['dbpwd'] = pwd - session['timeout'] = now - session['filter'] = filter - return render_template('list.html', flowers=F.all(), filter=filter) - #login failure - access_ctrl.deny(request.remote_addr) - print("Flowers - authorization failed for {}".format(user)) - return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Better next time") - - if 'timeout' in session and session['timeout']+datetime.timedelta(minutes=10)>now: - session['timeout'] = now - - if 'filter' in request.form.keys() and len(request.form['filter'])>1 and request.form['filter'] != DONOTSETFILTER: - session['filter']=request.form['filter'] - filter = request.form['filter'] - - F = Flower(session['dbuser'], session['dbpwd']) - if F.numberOfEntries()>=0: - - if action == 'list': - return render_template('list.html', flowers=F.all(), filter=filter) - - if action == 'show': - return render_template('show.html', flower=F.one(request.form['organization'], request.form['datetime'])) - - if action == 'edit': - return render_template('edit.html', flower=F.one(request.form['organization'], request.form['datetime'])) - - if action == 'new': - return render_template('edit.html', flower=F.empty()) - - if action == 'save': - flower = F.add(request.form['organization'],request.form['myname'],request.form['myid'],request.form['secret']) - session['filter']=request.form['organization'] - return render_template('show.html', flower=flower) - - if action == 'deactivate': - flower = F.deactivate(request.form['organization'],request.form['datetime']) - session['filter']='' - return render_template('show.html', flower=flower) - - if action == 'rehush': - return render_template('rehush.html', flower=F.empty()) - - - else: - return render_template('new.html', flower=F.empty()) - - - return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Better next time") - -@flower_bp.route('/create' , methods=['POST','GET']) -def create(): - access_ctrl = Access() - if not access_ctrl.granted(request.remote_addr): - return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Access denied") - - if 'name' not in request.form: - return render_template('create.html') - newuser = request.form['name'] - newhush = request.form['password'] - - # login db with generic user - f = SuperFlower(newuser, newhush) - if f.createNewTable(): - # login as new user - u = Flower(newuser, newhush) - # add one line - u.add('Demo organisation', 'Demo name', 'Demo login', 'Demo hush') - - # return to the login page - return render_template('index.html', name_suggestion="Now login", pwd_suggestion="for your very first time") - - return render_template('create.html', message="Sorry - System error - check log files") - - -@flower_bp.route('/update_pwd', methods=['POST','GET']) -def update_pwd(): - if 'name' not in request.form: - return render_template('rehush.html') - user = request.form['name'] - oldhush = request.form['old_password'] - newhush = request.form['new_password'] - - # login db with generic user - f = Flower(user, oldhush) - message = f.update_pwd(newhush) - - f = SuperFlower(user, oldhush) - f.flush_privs() - - return render_template('index.html', name_suggestion=message, pwd_suggestion="...") - - -# @app.route('/migrate', methods=['POST','GET']) -# def migrate(): -# user = 'ignace' -# hush = 'black' -# -# f = SuperFlower(user, hush) -# f.migrate(user) -# -# return render_template('index.html', name_suggestion='login again', pwd_suggestion="...") - -@flower_bp.route('/app' , methods=['POST']) -def web_service(): - # same thing as 'aplication, but returns go in json - result = json.dumps({'result': -1, 'message': 'Invalid entry'}) + selected_filter = session.get("filter", "") + form_filter = request.form.get("filter", "") + if len(form_filter) > 1 and form_filter != DONOTSETFILTER: + selected_filter = form_filter + session["filter"] = selected_filter try: - access_ctrl = Access() - if not access_ctrl.granted(request.remote_addr): - result = json.dumps({'result': -1, 'message': 'Access denied'}) + if action == "list": + return render_template("list.html", flowers=vault.all(), filter=selected_filter) + if action in {"show", "edit"}: + flower = vault.one( + request.form.get("organization", ""), request.form.get("datetime", "") + ) + return render_template(f"{action}.html", flower=flower, filter=selected_filter) + if action == "new": + return render_template("edit.html", flower=vault.empty(), filter=selected_filter) + if action == "save": + flower = vault.add( + request.form.get("organization", "").strip(), + request.form.get("myname", "").strip(), + request.form.get("myid", "").strip(), + request.form.get("secret", ""), + ) + if flower["dateCreated"] == "FAILED": + flash("Organization, login, and secret must each be at least four characters.", "error") + return render_template("edit.html", flower=flower, filter=selected_filter), 422 + session["filter"] = flower["organization"] + flash("Entry saved as a new version.", "success") + return render_template("show.html", flower=flower, filter=flower["organization"]) + if action == "deactivate": + flower = vault.deactivate( + request.form.get("organization", ""), request.form.get("datetime", "") + ) + session["filter"] = "" + flash("Entry removed from the active list.", "success") + return render_template("show.html", flower=flower, filter="") + if action == "rehush": + return render_template("rehush.html") + except StorageError: + flash("The vault database could not complete that request.", "error") + return redirect(url_for("flower_vase.index")) - else: - #no session variables here - the client will take care of fileter and timeout - action = request.form['action'] - user = request.form['name'] - pwd = request.form['password'] - F = Flower(user, pwd) - if F.numberOfEntries() == -1: - #login failure - access_ctrl.deny(request.remote_addr) - result = json.dumps({'result': 0, 'message': 'Invalid username or password'}) - - elif action == 'login': - result = json.dumps({'result': 1, 'message': 'Access granted'}) - - elif action == 'list': - result = json.dumps({'result': 1, 'message': 'Ok', 'list': json.dumps(F.all())}) - - elif action == 'one': - result = json.dumps({'result': 1, 'message': 'Ok', 'flower': json.dumps( F.one(request.form['organization'], request.form['dateCreated']) )}) - - elif action == 'save': - result = json.dumps({'result': 1, 'message': 'Ok', 'flower': json.dumps(F.add(request.form['organization'],request.form['myname'],request.form['myid'],request.form['secret']) )}) - - elif action == 'deactivate': - result = json.dumps({'result': 1, 'message': 'Ok', 'flower': json.dumps(F.deactivate(request.form['organization'], request.form['dateCreated']))}) - - except: - pass - return result + abort(400, description="Unknown action") +def _login(access: Access, client_ip: str): + username = request.form.get("name", "").strip() + password = request.form.get("password", "") + try: + vault = Flower(username, password) + authenticated = vault.authenticate() + except InvalidCredentials: + authenticated = False + if not authenticated: + access.deny(client_ip) + return _login_page("Sign-in failed", "Check your username and password", 401) + + old_token = session.get("auth_token") + _credentials().revoke(old_token) + session.clear() + session["csrf_token"] = secrets.token_urlsafe(32) + session["auth_token"] = _credentials().create(username, password) + session["filter"] = "" + return redirect(url_for("flower_vase.application")) + + +@flower_bp.route("/create", methods=["GET", "POST"]) +def create(): + if request.method == "GET": + return render_template("create.html") + access = Access() + client_ip = request.remote_addr or "unknown" + if not access.granted(client_ip): + return _login_page("Too many attempts", "Try again later", 429) + + username = request.form.get("name", "").strip() + password = request.form.get("password", "") + try: + provisioner = SuperFlower(username, password) + created = provisioner.createNewTable() + except InvalidCredentials as exc: + return render_template("create.html", message=str(exc)), 422 + if created: + Flower(username, password).add( + "Demo organisation", "Demo name", "Demo login", "Demo secret" + ) + flash("Your vault is ready. Sign in to continue.", "success") + return redirect(url_for("flower_vase.index")) + return render_template( + "create.html", message="The vault could not be created. Check the server log." + ), 500 + + +@flower_bp.route("/update_pwd", methods=["GET", "POST"]) +def update_pwd(): + if request.method == "GET": + return render_template("rehush.html") + try: + vault = Flower( + request.form.get("name", "").strip(), + request.form.get("old_password", ""), + ) + if not vault.authenticate(): + raise InvalidCredentials("Current credentials are not valid") + message = vault.update_pwd(request.form.get("new_password", "")) + except InvalidCredentials as exc: + return render_template("rehush.html", message=str(exc)), 422 + + _credentials().revoke(session.pop("auth_token", None)) + flash(message, "success" if message.startswith("SUCCESS") else "error") + return redirect(url_for("flower_vase.index")) + + +@flower_bp.post("/app") +def web_service(): + """Preserve the historical form API, including double-encoded payloads.""" + access = Access() + client_ip = request.remote_addr or "unknown" + if not access.granted(client_ip): + return jsonify(result=-1, message="Access denied") + + action = request.form.get("action", "") + username = request.form.get("name", "") + password = request.form.get("password", "") + try: + vault = Flower(username, password) + if not vault.authenticate(): + access.deny(client_ip) + return jsonify(result=0, message="Invalid username or password") + + if action == "login": + return jsonify(result=1, message="Access granted") + if action == "list": + return jsonify(result=1, message="Ok", list=json.dumps(vault.all())) + if action == "one": + item = vault.one( + request.form.get("organization", ""), + request.form.get("dateCreated", ""), + ) + return jsonify(result=1, message="Ok", flower=json.dumps(item)) + if action == "save": + item = vault.add( + request.form.get("organization", ""), + request.form.get("myname", ""), + request.form.get("myid", ""), + request.form.get("secret", ""), + ) + return jsonify(result=1, message="Ok", flower=json.dumps(item)) + if action == "deactivate": + item = vault.deactivate( + request.form.get("organization", ""), + request.form.get("dateCreated", ""), + ) + return jsonify(result=1, message="Ok", flower=json.dumps(item)) + except (InvalidCredentials, StorageError, ValueError): + current_app.logger.exception("Flowers API request failed") + + return jsonify(result=-1, message="Invalid entry") diff --git a/flowers.py b/flowers.py index 27b7940..9c4510d 100644 --- a/flowers.py +++ b/flowers.py @@ -1,16 +1,27 @@ +"""Flowers application factory.""" + from flask import Flask -from flower_vase import flower_bp + +from Config import COOKIE_SECURE, DEBUG, SECRET_KEY +from flower_vase import CredentialStore, flower_bp -def create_app(): +def create_app(test_config: dict | None = None) -> Flask: app = Flask(__name__) - app.config["SECRET_KEY"] = 'adhf adsh 8347y92347rupqo;wjf cowuyergc9b24387ryx1 -923pqr pqwejf qy7i34' + app.config.from_mapping( + SECRET_KEY=SECRET_KEY, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=COOKIE_SECURE, + MAX_CONTENT_LENGTH=64 * 1024, + ) + if test_config: + app.config.update(test_config) + + app.extensions["credential_store"] = CredentialStore() app.register_blueprint(flower_bp) return app -if __name__ == '__main__': - my_app = create_app() - # if needed - initialize data - my_app.run(debug=True, host='127.0.0.1', port=5012) - +if __name__ == "__main__": + create_app().run(debug=DEBUG, host="127.0.0.1", port=5012) diff --git a/flowers.wsgi b/flowers.wsgi index cdf6eb2..c713f1d 100644 --- a/flowers.wsgi +++ b/flowers.wsgi @@ -1,7 +1,9 @@ #!/usr/bin/python import sys -sys.path.insert(0, '/usr/share/projects/wsgi_apps/flowers') -from flowers import app as application +sys.path.insert(0, "/usr/share/projects/wsgi_apps/flowers") +from flowers import create_app + +application = create_app() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0287dac --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0,<10 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e94efb0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask>=3.1,<4 +fernet>=1.0,<2 +PyMySQL>=1.1,<3 +PyYAML>=6.0,<7 +waitress>=3.0,<4 diff --git a/secrets.example.yaml b/secrets.example.yaml new file mode 100644 index 0000000..a1d52bf --- /dev/null +++ b/secrets.example.yaml @@ -0,0 +1,6 @@ +# Copy this file to secrets.yaml and replace both values. +flask: + secret_key: "replace-with-a-long-random-value" + +database: + admin_password: "replace-with-the-flower-database-user-password" diff --git a/static/flower.css b/static/flower.css index 85e8172..b06658d 100644 --- a/static/flower.css +++ b/static/flower.css @@ -1,31 +1,117 @@ - -tr.flowerlist:hover td { - background:#eee; - cursor: pointer; +:root { + --bg: #0b1010; + --panel: #131b1a; + --panel-2: #182321; + --line: #293735; + --text: #f3f4ec; + --muted: #98a6a1; + --accent: #e5ff78; + --accent-ink: #172000; + --danger: #ff8e85; + --radius: 18px; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: var(--text); + background: var(--bg); + font-synthesis: none; } +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 12% 0%, #172522 0, transparent 35rem), var(--bg); } +button, input { font: inherit; } +a { color: inherit; } -.flowerlist { - width: 100%; +.site-header { min-height: 76px; padding: 16px clamp(20px, 5vw, 72px); display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); } +.brand { display: inline-flex; align-items: center; gap: 10px; font: 600 1.35rem Georgia, serif; text-decoration: none; } +.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; color: var(--accent); border: 1px solid #4c5b31; border-radius: 50%; } +.nav-actions { display: flex; align-items: center; gap: 8px; } +.nav-actions form { margin: 0; } +.text-link { color: var(--muted); text-decoration: none; font-size: .92rem; } +.text-link:hover { color: var(--text); } + +.page-shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: clamp(56px, 9vw, 112px) 0; min-height: calc(100vh - 150px); } +footer { padding: 24px; color: #65716e; text-align: center; font-size: .8rem; } +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 18px; font: 600 clamp(2.5rem, 7vw, 5.6rem)/.96 Georgia, serif; letter-spacing: -.045em; } +h2 { font: 600 1.55rem Georgia, serif; } +.eyebrow { margin-bottom: 14px; color: var(--accent); font-size: .74rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; } +.muted, .intro p { color: var(--muted); line-height: 1.7; } + +.auth-layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, 430px); align-items: center; gap: clamp(44px, 9vw, 120px); } +.intro { max-width: 620px; } +.intro p:not(.eyebrow) { max-width: 520px; font-size: 1.05rem; } +.card { background: linear-gradient(145deg, rgba(27, 39, 37, .94), rgba(16, 23, 22, .98)); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: 0 26px 80px rgba(0, 0, 0, .25); } +.form-card { display: flex; flex-direction: column; padding: clamp(28px, 5vw, 44px); } +.form-card label { margin: 18px 0 8px; color: #c8cfcb; font-size: .82rem; font-weight: 600; } +.form-card label span { color: var(--muted); font-weight: 400; } +input { width: 100%; padding: 14px 15px; color: var(--text); background: #0d1413; border: 1px solid #344340; border-radius: 10px; outline: none; } +input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(229, 255, 120, .1); } +input::placeholder { color: #61706c; } +.form-card .button { margin-top: 26px; } +.field-help { margin: 8px 0 0; color: var(--muted); font-size: .76rem; line-height: 1.5; } +.field-error { padding: 10px 12px; color: #ffd0cc; background: #3b1d1b; border-radius: 8px; font-size: .85rem; } + +.button, .icon-button { border: 0; cursor: pointer; } +.button { display: inline-flex; justify-content: center; align-items: center; min-height: 46px; padding: 0 19px; border-radius: 10px; font-weight: 700; } +.button.primary { color: var(--accent-ink); background: var(--accent); } +.button.primary:hover { background: #efffa9; transform: translateY(-1px); } +.button.ghost { color: var(--text); background: transparent; border: 1px solid var(--line); } +.button.ghost:hover { background: var(--panel-2); } +.button.compact { min-height: 38px; padding: 0 14px; font-size: .82rem; } +.button.danger { color: #2c0906; background: var(--danger); } + +.notices { position: fixed; z-index: 10; top: 88px; right: 24px; display: grid; gap: 8px; } +.notice { max-width: 380px; padding: 12px 16px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 35px #0008; font-size: .86rem; } +.notice-success { border-color: #53642f; } +.notice-error { border-color: #713b36; } + +.vault-header { display: flex; justify-content: space-between; align-items: end; gap: 32px; margin-bottom: 38px; } +.vault-header h1 { margin: 0; font-size: clamp(2.5rem, 6vw, 4.5rem); } +.search-field { width: min(100%, 360px); } +.entry-list { display: grid; gap: 10px; } +.entry-row { margin: 0; } +.entry-button { width: 100%; display: grid; grid-template-columns: 44px minmax(0, 1fr) auto 20px; align-items: center; gap: 15px; padding: 17px 18px; color: var(--text); text-align: left; background: rgba(19, 27, 26, .78); border: 1px solid var(--line); border-radius: 13px; cursor: pointer; } +.entry-button:hover { background: var(--panel-2); border-color: #42524f; transform: translateX(2px); } +.entry-icon { display: grid; place-items: center; width: 44px; height: 44px; color: var(--accent); background: #263020; border-radius: 12px; font: 600 1.15rem Georgia, serif; } +.entry-icon.large { width: 64px; height: 64px; font-size: 1.55rem; border-radius: 16px; } +.entry-copy { min-width: 0; display: grid; gap: 4px; } +.entry-copy strong, .entry-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.entry-copy span, .entry-button time { color: var(--muted); font-size: .82rem; } +.chevron { color: #6e7d78; font-size: 1.6rem; } +.empty-state { padding: 72px 20px; color: var(--muted); text-align: center; border: 1px dashed var(--line); border-radius: var(--radius); } +.empty-state > span { color: var(--accent); font-size: 2rem; } +.empty-state h2 { margin: 12px 0 6px; color: var(--text); } + +.detail-card { max-width: 760px; margin: 0 auto; padding: clamp(28px, 6vw, 58px); } +.detail-heading { display: flex; align-items: center; gap: 20px; padding-bottom: 32px; border-bottom: 1px solid var(--line); } +.detail-heading h1 { margin: 0 0 5px; font-size: clamp(2rem, 5vw, 3.5rem); overflow-wrap: anywhere; } +.detail-heading p { margin-bottom: 6px; } +.details { margin: 0; } +.details > div { display: grid; grid-template-columns: 130px 1fr; gap: 20px; padding: 24px 0; border-bottom: 1px solid var(--line); } +.details dt { color: var(--muted); font-size: .8rem; } +.details dd { margin: 0; overflow-wrap: anywhere; } +.secret-line { display: flex; align-items: center; gap: 10px; } +.secret-line > span { flex: 1; font-family: ui-monospace, monospace; } +.icon-button { padding: 7px 10px; color: var(--accent); background: #263020; border-radius: 7px; font-size: .76rem; } +.detail-actions { display: flex; justify-content: flex-end; padding-top: 30px; } + +.editor-layout { max-width: 760px; margin: 0 auto; } +.editor-layout > div:first-child { margin-bottom: 34px; } +.editor-layout h1 { font-size: clamp(2.6rem, 6vw, 4.5rem); } +.form-card.wide { padding: clamp(25px, 5vw, 44px); } +.danger-zone { display: flex; justify-content: flex-end; margin-top: 18px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +[hidden] { display: none !important; } + +@media (max-width: 760px) { + .site-header { align-items: flex-start; padding: 14px 20px; } + .nav-actions { flex-wrap: wrap; justify-content: flex-end; } + .auth-layout { grid-template-columns: 1fr; } + .page-shell { padding-top: 55px; } + .vault-header { align-items: stretch; flex-direction: column; } + .search-field { width: 100%; } + .entry-button { grid-template-columns: 40px minmax(0, 1fr) 14px; } + .entry-icon { width: 40px; height: 40px; } + .entry-button time { display: none; } + .details > div { grid-template-columns: 1fr; gap: 8px; } + .secret-line { flex-wrap: wrap; } } - -tr.flowerlist a { - color: inherit; /* blue colors for links too */ - text-decoration: inherit; /* no underline */ -} - -td.white { - color: #eee -} - -td.left { - text-align: left; -} - -td.right { - text-align: right; -} - -td.blue { - color: #208dd6; -} \ No newline at end of file diff --git a/templates/common.html b/templates/common.html index 1c7faef..f003e59 100644 --- a/templates/common.html +++ b/templates/common.html @@ -1,75 +1,37 @@ - - - - - - {% block title %}Flowers{% endblock %} - - - - - - - - - + + + + {% block title %}Flowers{% endblock %} + + -{% block headandmenu %} - -
-
- Flowers - - -
-
- -{% endblock headandmenu %} - - -{% block content %} -{% endblock %} - -
- - - - - -
- - - - -{% block footer %} - -{% endblock %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} +
+ {% block content %}{% endblock %} +
+
Private by design. Stored in your existing Flowers vault.
- diff --git a/templates/create.html b/templates/create.html index 8909a7d..f4c782f 100644 --- a/templates/create.html +++ b/templates/create.html @@ -1,35 +1,23 @@ {% extends "common.html" %} - -{% block headandmenu %} -
-
- Flowers - -
-
-{% endblock headandmenu %} - +{% block title %}Create a vault · Flowers{% endblock %} +{% block menuoptions %}Sign in{% endblock %} {% block content %} -
-
-
-
-
- -
- -
- -
- -
- -
-
-
- {{ message }} - -
-
- -{% endblock content %} +
+
+

A vase of your own

+

Create a vault.

+

This uses the existing Flowers database structure so it remains readable by compatible installations.

+
+
+ +

New vault

+ {% if message %}

{{ message }}

{% endif %} + + + + +

Use Base64-compatible characters for legacy encryption compatibility.

+ +
+
+{% endblock %} diff --git a/templates/edit.html b/templates/edit.html index 5db0c19..21b6e91 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -1,45 +1,41 @@ {% extends "common.html" %} - +{% block title %}{% if flower.organization %}Edit{% else %}New entry{% endif %} · Flowers{% endblock %} {% block menuoptions %} -
  • Save
  • -
  • Delete
  • -
  • Cancel
  • -{% endblock menuoptions %} - - -{% block content %} -
    -
    -
    -
    -
    - -
    - -
    - -
    - -
    - -
    - -
    - -
    - - - -
    - -
    - -
    -
    - - - - +
    + + + +
    +{% endblock %} +{% block content %} +
    +
    +

    {% if flower.organization %}New version{% else %}New credential{% endif %}

    +

    {% if flower.organization %}Edit entry{% else %}Add an entry{% endif %}

    +

    Saving creates a new version. Your historical rows remain untouched.

    +
    +
    + + + + + + + + + + + + +
    + {% if flower.dateCreated %} +
    + + + + + +
    + {% endif %} +
    {% endblock %} diff --git a/templates/index.html b/templates/index.html index 6da3a49..9e9c92b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,37 +1,24 @@ {% extends "common.html" %} - -{% block headandmenu %} -
    -
    - Flowers - -
    -
    -{% endblock headandmenu %} - +{% block title %}Sign in · Flowers{% endblock %} +{% block menuoptions %} + Create a vault +{% endblock %} {% block content %} -
    -
    -
    -
    -
    - -
    - -
    - -
    - -
    - -
    -
    - - - -
    - -
    -
    - -{% endblock content %} +
    +
    +

    Your private collection

    +

    Credentials, kept close.

    +

    Open your existing vault. Your encrypted records stay in the same database and format.

    +
    +
    + + +

    Sign in

    + + + + + +
    +
    +{% endblock %} diff --git a/templates/list.html b/templates/list.html index 4d6770a..203bec3 100644 --- a/templates/list.html +++ b/templates/list.html @@ -1,51 +1,78 @@ {% extends "common.html" %} - - +{% block title %}Your vault · Flowers{% endblock %} +{% block menuoptions %} +
    + + + +
    +
    + + + +
    +
    + + + +
    +{% endblock %} {% block content %} +
    +
    +

    Active entries

    +

    Your vault

    +
    + +
    -
    -
    -
    -
    - -
    -
    - - {% for flower in flowers %} - - - - - {% endfor %} -
    - - {{ flower.organization }}, {{ flower.myID }} - - {{ flower.dateCreated[:10] }}
    - -
    -
    - -
    -
    -
    - +
    + {% for flower in flowers %} +
    + + + + + + +
    + {% else %} +
    + +

    No active entries

    +

    Create an entry to start your collection.

    +
    + {% endfor %}
    + - - -{% endblock content %} +{% endblock %} diff --git a/templates/rehush.html b/templates/rehush.html index aff6838..a3b7017 100644 --- a/templates/rehush.html +++ b/templates/rehush.html @@ -1,40 +1,24 @@ {% extends "common.html" %} - -{% block headandmenu %} -
    -
    - Flowers - -
    -
    -{% endblock headandmenu %} - +{% block title %}Change password · Flowers{% endblock %} +{% block menuoptions %}Back to sign in{% endblock %} {% block content %} -
    -
    -
    -
    -
    - -
    - -
    - -
    - -
    - -
    - - -
    - -
    -
    -
    - {{ message }} - -
    -
    - -{% endblock content %} +
    +
    +

    Re-encrypt your vault

    +

    Change password.

    +

    Every existing encrypted field will be rewritten with the new legacy-compatible key.

    +
    +
    + +

    Vault credentials

    + {% if message %}

    {{ message }}

    {% endif %} + + + + + + + +
    +
    +{% endblock %} diff --git a/templates/show.html b/templates/show.html index 7c6dc91..6fbf99a 100644 --- a/templates/show.html +++ b/templates/show.html @@ -1,47 +1,61 @@ {% extends "common.html" %} - +{% block title %}{{ flower.organization }} · Flowers{% endblock %} {% block menuoptions %} -
  • Edit
  • -{% endblock menuoptions %} - - -{% block content %} -
    -
    - - - - - - -
    Org{{ flower.organization }}
    You{{ flower.myName }}
    Login{{ flower.myID }}
    Hash
    {{ flower.mySecret }}
    {{ flower.dateCreated[:10] }}
    - -
    -
    - - - - +
    + + + + +
    +{% endblock %} +{% block content %} +
    +
    + +
    +

    Credential

    +

    {{ flower.organization }}

    + {% if flower.dateCreated not in ('STORED', 'FAILED') %}

    Version from {{ flower.dateCreated[:10] }}

    {% endif %} +
    +
    +
    +
    Name
    {{ flower.myName or '—' }}
    +
    Login
    {{ flower.myID }}
    +
    +
    Secret
    +
    + •••••••••••• + + +
    +
    +
    + {% if flower.dateCreated not in ('STORED', 'FAILED') %} +
    +
    + + + + + +
    +
    + {% endif %} +
    + {% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..df56dea --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,74 @@ +import json +import unittest +from unittest.mock import patch + +from flowers import create_app + + +class FakeVault: + def __init__(self, *args): + pass + + def authenticate(self): + return True + + def all(self): + return [ + { + "organization": "Acme", + "myID": "alice@example.test", + "dateCreated": "2024-02-03 12:00:00", + } + ] + + +class AppTests(unittest.TestCase): + def setUp(self): + self.app = create_app({"TESTING": True, "SECRET_KEY": "test-secret"}) + self.client = self.app.test_client() + + def csrf(self): + self.client.get("/") + with self.client.session_transaction() as browser_session: + return browser_session["csrf_token"] + + def test_login_form_requires_csrf(self): + response = self.client.post( + "/browser", + data={"action": "login", "name": "alice", "password": "legacy_pwd"}, + ) + self.assertEqual(response.status_code, 400) + + @patch("flower_vase.Flower", FakeVault) + def test_login_renders_existing_entries(self): + response = self.client.post( + "/browser", + data={ + "csrf_token": self.csrf(), + "action": "login", + "name": "alice", + "password": "legacy_pwd", + }, + follow_redirects=True, + ) + self.assertEqual(response.status_code, 200) + self.assertIn(b"Acme", response.data) + with self.client.session_transaction() as browser_session: + self.assertNotIn("dbpwd", browser_session) + self.assertNotIn("password", browser_session) + self.assertIn("auth_token", browser_session) + + @patch("flower_vase.Flower", FakeVault) + def test_legacy_api_keeps_double_encoded_list(self): + response = self.client.post( + "/app", + data={"action": "list", "name": "alice", "password": "legacy_pwd"}, + ) + payload = response.get_json() + nested = json.loads(payload["list"]) + self.assertEqual(payload["result"], 1) + self.assertEqual(nested[0]["organization"], "Acme") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..5259016 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,120 @@ +import unittest + +from FlowerServices import Flower, InvalidCredentials, legacy_key + + +class FakeCursor: + def __init__(self, one=None, many=(), changed=1): + self.one = one + self.many = many + self.changed = changed + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def execute(self, sql, parameters=None): + self.calls.append((" ".join(sql.split()), parameters)) + return self.changed + + def fetchone(self): + return self.one + + def fetchall(self): + return self.many + + +class FakeConnection: + def __init__(self, cursor): + self.fake_cursor = cursor + self.commits = 0 + self.rollbacks = 0 + self.closed = 0 + + def cursor(self): + return self.fake_cursor + + def commit(self): + self.commits += 1 + + def rollback(self): + self.rollbacks += 1 + + def close(self): + self.closed += 1 + + +class FlowerCompatibilityTests(unittest.TestCase): + def make_vault(self, cursor): + connection = FakeConnection(cursor) + calls = [] + + def connect(**kwargs): + calls.append(kwargs) + return connection + + return Flower("alice", "legacy_pwd", connect), connection, calls + + def test_legacy_key_derivation_is_unchanged(self): + self.assertEqual(legacy_key("black"), "blackblackblackblackblackblackblackblackbla=") + + def test_legacy_ciphertext_round_trip(self): + vault, _, _ = self.make_vault(FakeCursor()) + token = vault.encode("existing database secret") + self.assertEqual(vault.decode(token), "existing database secret") + + def test_existing_row_is_decrypted_without_schema_changes(self): + seed, _, _ = self.make_vault(FakeCursor()) + row = (seed.encode("secret"), seed.encode("login"), seed.encode("Alice")) + cursor = FakeCursor(one=row) + vault, _, calls = self.make_vault(cursor) + + item = vault.one("Acme", "2024-02-03 12:00:00") + + self.assertEqual(item["mySecret"], "secret") + self.assertEqual(item["myID"], "login") + self.assertEqual(item["myName"], "Alice") + self.assertEqual( + cursor.calls[0][1], ("Acme", "2024-02-03 12:00:00") + ) + self.assertEqual(calls[0]["user"], "alice_") + self.assertEqual(calls[0]["db"], "Flowers") + + def test_writes_use_parameters_and_existing_columns(self): + cursor = FakeCursor() + vault, connection, _ = self.make_vault(cursor) + hostile_label = 'Acme"; DROP TABLE alice_; --' + + result = vault.add(hostile_label, "Alice", "login", "secret") + + sql, parameters = cursor.calls[0] + self.assertIn("organization, myID, myName, mySecret, deleted", sql) + self.assertNotIn(hostile_label, sql) + self.assertEqual(parameters[0], hostile_label) + self.assertEqual(result["dateCreated"], "STORED") + self.assertEqual(connection.commits, 1) + + def test_latest_active_list_keeps_legacy_query_semantics(self): + seed, _, _ = self.make_vault(FakeCursor()) + cursor = FakeCursor( + many=(("Acme", seed.encode("login"), "2024-02-03 12:00:00"),) + ) + vault, _, _ = self.make_vault(cursor) + + items = vault.all() + + self.assertEqual(items[0]["organization"], "Acme") + self.assertEqual(items[0]["myID"], "login") + self.assertIn("MAX(dateCreated)", cursor.calls[0][0]) + self.assertEqual(cursor.calls[0][1], (0,)) + + def test_unsafe_table_identifier_is_rejected(self): + with self.assertRaises(InvalidCredentials): + Flower("alice`; DROP DATABASE Flowers", "legacy_pwd") + + +if __name__ == "__main__": + unittest.main()