rewritten after codex recommendations
This commit is contained in:
@@ -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
|
||||
|
||||
+58
-30
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
+303
-245
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 `<username>_` 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 `<username>_`;
|
||||
- a table named `<username>_` 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.
|
||||
|
||||
+310
-184
@@ -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/<filename>')
|
||||
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")
|
||||
|
||||
+19
-8
@@ -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)
|
||||
|
||||
+4
-2
@@ -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()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0,<10
|
||||
@@ -0,0 +1,5 @@
|
||||
Flask>=3.1,<4
|
||||
fernet>=1.0,<2
|
||||
PyMySQL>=1.1,<3
|
||||
PyYAML>=6.0,<7
|
||||
waitress>=3.0,<4
|
||||
@@ -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"
|
||||
+108
-22
@@ -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); }
|
||||
|
||||
tr.flowerlist a {
|
||||
color: inherit; /* blue colors for links too */
|
||||
text-decoration: inherit; /* no underline */
|
||||
}
|
||||
.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; }
|
||||
|
||||
td.white {
|
||||
color: #eee
|
||||
}
|
||||
.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; }
|
||||
|
||||
td.left {
|
||||
text-align: left;
|
||||
}
|
||||
.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); }
|
||||
|
||||
td.right {
|
||||
text-align: right;
|
||||
}
|
||||
.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; }
|
||||
|
||||
td.blue {
|
||||
color: #208dd6;
|
||||
.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; }
|
||||
}
|
||||
+27
-65
@@ -1,75 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>{% block title %}Flowers{% endblock %}</title>
|
||||
|
||||
<!-- link rel="stylesheet" href="http://yui.yahooapis.com/pure/0.6.0/pure-min.css" -->
|
||||
<link rel="stylesheet" href="static/pure-min.css">
|
||||
<link rel="stylesheet" href="static/grids-responsive-min.css">
|
||||
<link rel="stylesheet" href="static/font-awesome.css">
|
||||
<link rel=stylesheet type=text/css href="static/marketing.css">
|
||||
<link rel=stylesheet type=text/css href="static/flower.css">
|
||||
<script type="text/javascript" src="static/jquery-min.js"></script>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>{% block title %}Flowers{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('flower_vase.static', filename='flower.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="{{ url_for('flower_vase.index') }}" aria-label="Flowers home">
|
||||
<span class="brand-mark" aria-hidden="true">✣</span>
|
||||
<span>Flowers</span>
|
||||
</a>
|
||||
<nav class="nav-actions" aria-label="Vault actions">
|
||||
{% block menuoptions %}{% endblock %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% block headandmenu %}
|
||||
|
||||
<div class="header">
|
||||
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
|
||||
<a class="pure-menu-heading" href="">Flowers</a>
|
||||
|
||||
<ul class="pure-menu-list">
|
||||
{% block menuoptions %}
|
||||
<input type="text" id="search" placeholder="search..." value="{{filter}}">
|
||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('new')" class="pure-menu-link">New</a></li>
|
||||
<li class="pure-menu-item"><a href="javascript:DoSubmit('logout')" class="pure-menu-link">Logout</a></li>
|
||||
<li class="pure-menu-item"><a href="javascript:DoSubmit('rehush')" class="pure-menu-link">Password</a></li>
|
||||
{% endblock menuoptions %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock headandmenu %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
<form id="gotopage" action="{{ url_for('flower_vase.application') }}" method="POST">
|
||||
<input type="hidden" id="_action" name="action" value="">
|
||||
<input type="hidden" id="_organization" name="organization" value="">
|
||||
<input type="hidden" id="_myid" name="myid" value="">
|
||||
<input type="hidden" id="_datetime" name="datetime" value="">
|
||||
<input type="hidden" id="_filter" name="filter" value="">
|
||||
</form>
|
||||
|
||||
<script language="javascript">
|
||||
|
||||
function DoSubmit(action, org, datetime, filter) {
|
||||
$('#_action').val(action);
|
||||
$('#_organization').val(org);
|
||||
$('#_datetime').val(datetime);
|
||||
$('#_filter').val( $('#search').val() );
|
||||
$('#gotopage').submit();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
{% block footer %}
|
||||
<div class="footer l-box is-center">
|
||||
Thank you for using the flowershop.
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="notices" aria-live="polite">
|
||||
{% for category, message in messages %}
|
||||
<div class="notice notice-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<main class="page-shell">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>Private by design. Stored in your existing Flowers vault.</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+21
-33
@@ -1,35 +1,23 @@
|
||||
{% extends "common.html" %}
|
||||
|
||||
{% block headandmenu %}
|
||||
<div class="header">
|
||||
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
|
||||
<a class="pure-menu-heading" href="">Flowers</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock headandmenu %}
|
||||
|
||||
{% block title %}Create a vault · Flowers{% endblock %}
|
||||
{% block menuoptions %}<a class="text-link" href="{{ url_for('flower_vase.index') }}">Sign in</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="splash-container">
|
||||
<div class="splash">
|
||||
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.create') }}" method="post">
|
||||
<fieldset>
|
||||
<div class="pure-control-group">
|
||||
<input maxlength="32" name="name" type="text" placeholder="make up your username">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<input maxlength="10" name="password" type="password" placeholder="make up you password, the longer the better">
|
||||
</div>
|
||||
|
||||
<div class="pure-controls">
|
||||
<button type="submit" class="pure-button pure-button-primary">Create Vase</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
{{ message }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock content %}
|
||||
<section class="auth-layout">
|
||||
<div class="intro">
|
||||
<p class="eyebrow">A vase of your own</p>
|
||||
<h1>Create a vault.</h1>
|
||||
<p>This uses the existing Flowers database structure so it remains readable by compatible installations.</p>
|
||||
</div>
|
||||
<form class="card form-card" action="{{ url_for('flower_vase.create') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h2>New vault</h2>
|
||||
{% if message %}<p class="field-error">{{ message }}</p>{% endif %}
|
||||
<label for="name">Username</label>
|
||||
<input maxlength="31" id="name" name="name" type="text" pattern="[A-Za-z0-9_]+" autocomplete="username" required>
|
||||
<label for="password">Password</label>
|
||||
<input maxlength="43" id="password" name="password" type="password" pattern="[A-Za-z0-9_+/-]+" autocomplete="new-password" required>
|
||||
<p class="field-help">Use Base64-compatible characters for legacy encryption compatibility.</p>
|
||||
<button class="button primary" type="submit">Create vault</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+38
-42
@@ -1,45 +1,41 @@
|
||||
{% extends "common.html" %}
|
||||
|
||||
{% block title %}{% if flower.organization %}Edit{% else %}New entry{% endif %} · Flowers{% endblock %}
|
||||
{% block menuoptions %}
|
||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:$('#newFlower').submit()" class="pure-menu-link">Save</a></li>
|
||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('deactivate','{{ flower.organization }}','{{ flower.dateCreated }}','')" class="pure-menu-link">Delete</a></li>
|
||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('list','','','')" class="pure-menu-link">Cancel</a></li>
|
||||
{% endblock menuoptions %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="splash-container">
|
||||
<div class="splash">
|
||||
<form class="pure-form pure-form-stacked" id="newFlower" action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<fieldset>
|
||||
<div class="pure-control-group">
|
||||
<input maxlength="70" size="50" id="organization" name="organization" type="text" value="{{ flower.organization }}" placeholder="Organization">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<input maxlength="70" id="myname" name="myname" type="text" value="{{ flower.myName }}" placeholder="Your name (optional)">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<input maxlength="70" id="myid" name="myid" type="text" value="{{ flower.myID }}" placeholder="Your Login">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<input maxlength="30" id="secret" name="secret" type="text" placeholder="Your secret">
|
||||
</div>
|
||||
<input type="hidden" id="action" name="action" value="save">
|
||||
<input type="hidden" id="filter" name="filter" value="DoNotSetFilterinCookie">
|
||||
|
||||
</fieldset>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script language="javascript">
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="list">
|
||||
<button class="button ghost compact" type="submit">Cancel</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="editor-layout">
|
||||
<div>
|
||||
<p class="eyebrow">{% if flower.organization %}New version{% else %}New credential{% endif %}</p>
|
||||
<h1>{% if flower.organization %}Edit entry{% else %}Add an entry{% endif %}</h1>
|
||||
<p class="muted">Saving creates a new version. Your historical rows remain untouched.</p>
|
||||
</div>
|
||||
<form class="card form-card wide" action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="filter" value="{{ filter|default('') }}">
|
||||
<label for="organization">Organization</label>
|
||||
<input maxlength="80" id="organization" name="organization" type="text" value="{{ flower.organization }}" autocomplete="organization" required>
|
||||
<label for="myname">Your name <span>optional</span></label>
|
||||
<input maxlength="70" id="myname" name="myname" type="text" value="{{ flower.myName }}" autocomplete="name">
|
||||
<label for="myid">Login</label>
|
||||
<input maxlength="70" id="myid" name="myid" type="text" value="{{ flower.myID }}" autocomplete="username" required>
|
||||
<label for="secret">Secret</label>
|
||||
<input maxlength="120" id="secret" name="secret" type="password" autocomplete="new-password" required>
|
||||
<button class="button primary" type="submit">Save new version</button>
|
||||
</form>
|
||||
{% if flower.dateCreated %}
|
||||
<form class="danger-zone" action="{{ url_for('flower_vase.application') }}" method="post" onsubmit="return confirm('Remove this entry from the active list?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="deactivate">
|
||||
<input type="hidden" name="organization" value="{{ flower.organization }}">
|
||||
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
|
||||
<button class="button danger" type="submit">Deactivate entry</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+22
-35
@@ -1,37 +1,24 @@
|
||||
{% extends "common.html" %}
|
||||
|
||||
{% block headandmenu %}
|
||||
<div class="header">
|
||||
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
|
||||
<a class="pure-menu-heading" href="">Flowers</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock headandmenu %}
|
||||
|
||||
{% block title %}Sign in · Flowers{% endblock %}
|
||||
{% block menuoptions %}
|
||||
<a class="text-link" href="{{ url_for('flower_vase.create') }}">Create a vault</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="splash-container">
|
||||
<div class="splash">
|
||||
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<fieldset>
|
||||
<div class="pure-control-group">
|
||||
<input name="name" type="text" placeholder="{{ name_suggestion }}">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<input name="password" type="password" placeholder="{{ pwd_suggestion }}">
|
||||
</div>
|
||||
|
||||
<div class="pure-controls">
|
||||
<button type="submit" class="pure-button pure-button-primary">Submit</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<input type="hidden" id="action" name="action" value="login">
|
||||
<input type="hidden" id="filter" name="filter" value="DoNotSetFilterinCookie">
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock content %}
|
||||
<section class="auth-layout">
|
||||
<div class="intro">
|
||||
<p class="eyebrow">Your private collection</p>
|
||||
<h1>Credentials, kept close.</h1>
|
||||
<p>Open your existing vault. Your encrypted records stay in the same database and format.</p>
|
||||
</div>
|
||||
<form class="card form-card" action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="login">
|
||||
<h2>Sign in</h2>
|
||||
<label for="name">Username</label>
|
||||
<input id="name" name="name" type="text" autocomplete="username" placeholder="{{ name_suggestion }}" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="{{ pwd_suggestion }}" required>
|
||||
<button class="button primary" type="submit">Open vault</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+72
-45
@@ -1,51 +1,78 @@
|
||||
{% extends "common.html" %}
|
||||
|
||||
|
||||
{% block title %}Your vault · Flowers{% endblock %}
|
||||
{% block menuoptions %}
|
||||
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="new">
|
||||
<button class="button primary compact" type="submit">New entry</button>
|
||||
</form>
|
||||
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="rehush">
|
||||
<button class="button ghost compact" type="submit">Password</button>
|
||||
</form>
|
||||
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="logout">
|
||||
<button class="button ghost compact" type="submit">Sign out</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="vault-header">
|
||||
<div>
|
||||
<p class="eyebrow">Active entries</p>
|
||||
<h1>Your vault</h1>
|
||||
</div>
|
||||
<label class="search-field" for="search">
|
||||
<span class="sr-only">Search entries</span>
|
||||
<input id="search" type="search" placeholder="Search organization or login" value="{{ filter|default('') }}" autocomplete="off">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="content">
|
||||
<div class="pure-g">
|
||||
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-5">
|
||||
|
||||
</div>
|
||||
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-3-5">
|
||||
<table id="flowerlist" class="flowerlist pure-table pure-table-horizontal">
|
||||
{% for flower in flowers %}
|
||||
<tr class="flowerlist">
|
||||
<td class="flowerlist">
|
||||
<a href="javascript:DoSubmit('show', '{{flower.organization}}', '{{flower.dateCreated}}' , '')">
|
||||
{{ flower.organization }}, {{ flower.myID }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="right"><span style="white-space: nowrap;">{{ flower.dateCreated[:10] }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-5">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry-list" id="entry-list">
|
||||
{% for flower in flowers %}
|
||||
<form class="entry-row" data-search="{{ flower.organization }} {{ flower.myID }}" action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="show">
|
||||
<input type="hidden" name="organization" value="{{ flower.organization }}">
|
||||
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
|
||||
<input type="hidden" name="filter" class="current-filter" value="{{ filter|default('') }}">
|
||||
<button type="submit" class="entry-button">
|
||||
<span class="entry-icon" aria-hidden="true">{{ flower.organization[:1]|upper }}</span>
|
||||
<span class="entry-copy">
|
||||
<strong>{{ flower.organization }}</strong>
|
||||
<span>{{ flower.myID }}</span>
|
||||
</span>
|
||||
<time datetime="{{ flower.dateCreated }}">{{ flower.dateCreated[:10] }}</time>
|
||||
<span class="chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<span aria-hidden="true">✣</span>
|
||||
<h2>No active entries</h2>
|
||||
<p>Create an entry to start your collection.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="empty-state filtered-empty" id="filtered-empty" hidden>No entries match that search.</p>
|
||||
|
||||
<script language="javascript">
|
||||
|
||||
var $rows = $('#flowerlist tr');
|
||||
$('#search').keyup(function() {
|
||||
var val = $.trim($(this).val()).replace(/ +/g, ' ').toLowerCase();
|
||||
|
||||
$rows.show().filter(function() {
|
||||
var text = $(this).text().replace(/\s+/g, ' ').toLowerCase();
|
||||
return !~text.indexOf(val);
|
||||
}).hide();
|
||||
});
|
||||
|
||||
$('#search').keyup();
|
||||
|
||||
<script>
|
||||
const search = document.querySelector('#search');
|
||||
const rows = [...document.querySelectorAll('.entry-row')];
|
||||
const filteredEmpty = document.querySelector('#filtered-empty');
|
||||
function filterRows() {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
let visible = 0;
|
||||
rows.forEach((row) => {
|
||||
const match = row.dataset.search.toLocaleLowerCase().includes(query);
|
||||
row.hidden = !match;
|
||||
row.querySelector('.current-filter').value = search.value;
|
||||
if (match) visible += 1;
|
||||
});
|
||||
filteredEmpty.hidden = visible !== 0 || rows.length === 0;
|
||||
}
|
||||
search.addEventListener('input', filterRows);
|
||||
filterRows();
|
||||
</script>
|
||||
|
||||
{% endblock content %}
|
||||
{% endblock %}
|
||||
|
||||
+22
-38
@@ -1,40 +1,24 @@
|
||||
{% extends "common.html" %}
|
||||
|
||||
{% block headandmenu %}
|
||||
<div class="header">
|
||||
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
|
||||
<a class="pure-menu-heading" href="">Flowers</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock headandmenu %}
|
||||
|
||||
{% block title %}Change password · Flowers{% endblock %}
|
||||
{% block menuoptions %}<a class="text-link" href="{{ url_for('flower_vase.index') }}">Back to sign in</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="splash-container">
|
||||
<div class="splash">
|
||||
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.update_pwd') }}" method="post">
|
||||
<fieldset>
|
||||
<div class="pure-control-group">
|
||||
<input name="name" type="text" placeholder="your existing name">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<input name="old_password" type="password" placeholder="your existing password">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<input maxlength="10" name="new_password" type="password" placeholder="make up you password, the longer the better">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pure-controls">
|
||||
<button type="submit" class="pure-button pure-button-primary">Update Password</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
{{ message }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock content %}
|
||||
<section class="auth-layout">
|
||||
<div class="intro">
|
||||
<p class="eyebrow">Re-encrypt your vault</p>
|
||||
<h1>Change password.</h1>
|
||||
<p>Every existing encrypted field will be rewritten with the new legacy-compatible key.</p>
|
||||
</div>
|
||||
<form class="card form-card" action="{{ url_for('flower_vase.update_pwd') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h2>Vault credentials</h2>
|
||||
{% if message %}<p class="field-error">{{ message }}</p>{% endif %}
|
||||
<label for="name">Username</label>
|
||||
<input id="name" name="name" type="text" autocomplete="username" required>
|
||||
<label for="old-password">Current password</label>
|
||||
<input id="old-password" name="old_password" type="password" autocomplete="current-password" required>
|
||||
<label for="new-password">New password</label>
|
||||
<input maxlength="43" id="new-password" name="new_password" type="password" pattern="[A-Za-z0-9_+/-]+" autocomplete="new-password" required>
|
||||
<button class="button primary" type="submit">Re-encrypt vault</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+58
-44
@@ -1,47 +1,61 @@
|
||||
{% extends "common.html" %}
|
||||
|
||||
{% block title %}{{ flower.organization }} · Flowers{% endblock %}
|
||||
{% block menuoptions %}
|
||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:editFlower()" class="pure-menu-link">Edit</a></li>
|
||||
{% endblock menuoptions %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="splash-container">
|
||||
<div class="splash">
|
||||
<table class="pure-table" style="width: 100%">
|
||||
<tr><td class="white right">Org</td><td class="white left">{{ flower.organization }}</td> </tr>
|
||||
<tr><td class="white right">You</td><td class="white left">{{ flower.myName }}</td> </tr>
|
||||
<tr><td class="white right">Login</td><td class="white left">{{ flower.myID }}</td> </tr>
|
||||
<tr><td class="white right">Hash</td><td id="secrettd" class="white left"><div id="secret" value="{{ flower.mySecret }}" onclick="myCopyFunction()">{{ flower.mySecret }}</div></td></tr>
|
||||
<tr><td class="white">{{ flower.dateCreated[:10] }}</td><td></td> </tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script language="javascript">
|
||||
|
||||
setTimeout(function(){
|
||||
DoSubmit('list', '{{flower.organization}}', '{{flower.dateCreated}}', 'DoNotSetFilterinCookie')
|
||||
}, 5000);
|
||||
|
||||
function editFlower() {
|
||||
DoSubmit('edit', '{{flower.organization}}', '{{flower.dateCreated}}', 'DoNotSetFilterinCookie')
|
||||
}
|
||||
|
||||
function myCopyFunction() {
|
||||
var copyText = document.getElementById("secret");
|
||||
|
||||
// Copy the text inside the text field
|
||||
navigator.clipboard.writeText(copyText.getAttribute("value"));
|
||||
|
||||
// alert("Copied the text: "+copyText.getAttribute("value"));
|
||||
|
||||
$("#secrettd").removeClass("white");
|
||||
$("#secrettd").addClass("blue");
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="list">
|
||||
<input type="hidden" name="filter" value="{{ filter|default('') }}">
|
||||
<button class="button ghost compact" type="submit">Back to vault</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="detail-card card">
|
||||
<div class="detail-heading">
|
||||
<span class="entry-icon large" aria-hidden="true">{{ flower.organization[:1]|upper }}</span>
|
||||
<div>
|
||||
<p class="eyebrow">Credential</p>
|
||||
<h1>{{ flower.organization }}</h1>
|
||||
{% if flower.dateCreated not in ('STORED', 'FAILED') %}<p class="muted">Version from {{ flower.dateCreated[:10] }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<dl class="details">
|
||||
<div><dt>Name</dt><dd>{{ flower.myName or '—' }}</dd></div>
|
||||
<div><dt>Login</dt><dd>{{ flower.myID }}</dd></div>
|
||||
<div>
|
||||
<dt>Secret</dt>
|
||||
<dd class="secret-line">
|
||||
<span id="secret" data-secret="{{ flower.mySecret }}">••••••••••••</span>
|
||||
<button class="icon-button" id="reveal" type="button">Reveal</button>
|
||||
<button class="icon-button" id="copy" type="button">Copy</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% if flower.dateCreated not in ('STORED', 'FAILED') %}
|
||||
<div class="detail-actions">
|
||||
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="edit">
|
||||
<input type="hidden" name="organization" value="{{ flower.organization }}">
|
||||
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
|
||||
<button class="button primary" type="submit">Edit entry</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
const secret = document.querySelector('#secret');
|
||||
const reveal = document.querySelector('#reveal');
|
||||
const copy = document.querySelector('#copy');
|
||||
let visible = false;
|
||||
reveal.addEventListener('click', () => {
|
||||
visible = !visible;
|
||||
secret.textContent = visible ? secret.dataset.secret : '••••••••••••';
|
||||
reveal.textContent = visible ? 'Hide' : 'Reveal';
|
||||
});
|
||||
copy.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(secret.dataset.secret);
|
||||
copy.textContent = 'Copied';
|
||||
window.setTimeout(() => copy.textContent = 'Copy', 1400);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user