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__/flowers.cpython-314.pyc
|
||||||
__pycache__/FlowerServices.cpython-314.pyc
|
__pycache__/FlowerServices.cpython-314.pyc
|
||||||
__pycache__/Log.cpython-314.pyc
|
__pycache__/Log.cpython-314.pyc
|
||||||
|
__pycache__/
|
||||||
|
tests/__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
secrets.yaml
|
||||||
|
|||||||
+58
-30
@@ -1,40 +1,68 @@
|
|||||||
from Config import *
|
"""Small file-backed login throttle.
|
||||||
import pickle, os
|
|
||||||
import datetime
|
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
|
from Log import Log
|
||||||
|
|
||||||
class Access():
|
|
||||||
access_list = []
|
|
||||||
|
|
||||||
def __init__(self):
|
class Access:
|
||||||
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
limit = 3
|
||||||
new_list = []
|
window = timedelta(minutes=5)
|
||||||
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
|
|
||||||
|
|
||||||
def granted(self, ipaddress):
|
def __init__(self, path: str = ACCESSFILE) -> None:
|
||||||
result = 0
|
self.path = Path(path)
|
||||||
for entry in self.access_list:
|
self.access_list = self._load()
|
||||||
if entry['ip'] == ipaddress:
|
|
||||||
result += 1
|
|
||||||
|
|
||||||
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
|
return True
|
||||||
|
Log.info(f"Access denied for {ipaddress}")
|
||||||
Log.info("Access denied for {}".format(ipaddress))
|
|
||||||
self.deny(ipaddress)
|
|
||||||
return False
|
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):
|
def _load(self) -> list[dict[str, str]]:
|
||||||
now = datetime.datetime.now()
|
cutoff = datetime.now(timezone.utc) - self.window
|
||||||
self.access_list.append({'ip': ipaddress, 'time': now})
|
try:
|
||||||
with open(ACCESSFILE, 'wb') as pf:
|
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
pickle.dump(self.access_list, pf, 2)
|
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'
|
Secrets are read from the git-ignored ``secrets.yaml`` file. Environment
|
||||||
DEBUG = 0
|
variables take precedence, which is useful for container deployments.
|
||||||
MAXTEXTLEN = 70
|
"""
|
||||||
MINFIELDLEN = 3
|
|
||||||
INFO = 1
|
import os
|
||||||
DONOTSETFILTER = 'DoNotSetFilterinCookie'
|
import secrets
|
||||||
ACCESSFILE = '/tmp/wsgi_flower_accessfile'
|
from pathlib import Path
|
||||||
URLPREFIX = '' #'/flowers'
|
|
||||||
|
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
|
"""Database and encryption services for the legacy Flowers data format.
|
||||||
from Log import Log
|
|
||||||
from Config import *
|
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
|
import pymysql as mdb
|
||||||
|
from fernet import Fernet
|
||||||
|
|
||||||
#encryption stuff
|
from Config import (
|
||||||
SECRET = b'XBxB603cX_mULEXxfavOg3FDc0Ox3gChwYEY-Uxd3tE='
|
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'
|
class InvalidCredentials(ValueError):
|
||||||
self.db_username = user + '_'
|
"""The supplied username/password cannot address a Flowers vault."""
|
||||||
self.db_password = (pwd*10)[:43]+"="
|
|
||||||
|
|
||||||
|
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.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):
|
@contextmanager
|
||||||
if len(s)==0:
|
def _connection(self) -> Iterator[object]:
|
||||||
return ''
|
connection = None
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
|
connection = self._connect(
|
||||||
cur = con.cursor()
|
host=DB_HOST,
|
||||||
if ";\n" in sql:
|
port=DB_PORT,
|
||||||
for s in sql.split(";\n"):
|
user=self.db_username,
|
||||||
if len(s)>1:
|
passwd=self.db_password,
|
||||||
cur.execute(s)
|
db=self.db,
|
||||||
else:
|
charset="latin1",
|
||||||
cur.execute(sql)
|
)
|
||||||
cur.close()
|
yield connection
|
||||||
con.commit()
|
except mdb.Error as exc:
|
||||||
result = 1
|
Log.debug(f"Database error: {exc}")
|
||||||
except mdb.Error as e:
|
raise StorageError("The vault database is unavailable") from exc
|
||||||
m = "Error %d: %s" % (e.args[0],e.args[1])
|
|
||||||
data = {'message': m}
|
|
||||||
Log.debug(m)
|
|
||||||
result = -1
|
|
||||||
finally:
|
finally:
|
||||||
if con:
|
if connection is not None:
|
||||||
con.close()
|
connection.close()
|
||||||
return result, data
|
|
||||||
|
|
||||||
|
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):
|
def decode(self, value: str) -> str:
|
||||||
Log.debug('1row sql: '+sql)
|
if not value:
|
||||||
result = 0
|
return ""
|
||||||
con = None
|
return self.fernet.decrypt(value.encode("ascii")).decode("utf-8")
|
||||||
|
|
||||||
|
def authenticate(self) -> bool:
|
||||||
try:
|
try:
|
||||||
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
|
with self._connection() as connection:
|
||||||
cur = con.cursor()
|
with connection.cursor() as cursor:
|
||||||
cur.execute(sql)
|
cursor.execute(f"SELECT COUNT(*) FROM {self._table}")
|
||||||
data = cur.fetchone()
|
cursor.fetchone()
|
||||||
if data:
|
return True
|
||||||
result = 1
|
except (StorageError, ValueError):
|
||||||
except mdb.Error as e:
|
return False
|
||||||
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
|
|
||||||
|
|
||||||
def do_db_allrows(self, sql):
|
def numberOfEntries(self) -> int:
|
||||||
Log.debug('all rows sql: '+sql)
|
"""Compatibility method used by older clients and scripts."""
|
||||||
result = 0
|
|
||||||
con = None
|
|
||||||
try:
|
try:
|
||||||
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
|
with self._connection() as connection:
|
||||||
cur = con.cursor()
|
with connection.cursor() as cursor:
|
||||||
cur.execute(sql)
|
cursor.execute(f"SELECT COUNT(*) FROM {self._table}")
|
||||||
data = cur.fetchall()
|
row = cursor.fetchone()
|
||||||
if data:
|
return int(row[0]) if row else 0
|
||||||
result = 1
|
except StorageError:
|
||||||
except mdb.Error as e:
|
return -1
|
||||||
m = "Error %d: %s" % (e.args[0],e.args[1])
|
|
||||||
data = {'message': m}
|
def add(
|
||||||
Log.debug(m)
|
self,
|
||||||
result = -1
|
organization: str,
|
||||||
finally:
|
myname: str,
|
||||||
if con:
|
myid: str,
|
||||||
con.close()
|
secret: str,
|
||||||
return result, data
|
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):
|
class SuperFlower(Flower):
|
||||||
|
"""Provision a legacy per-user table and database account."""
|
||||||
|
|
||||||
def __init__(self, customer, pwd):
|
def __init__(self, customer: str, pwd: str, connection_factory=None) -> None:
|
||||||
super().__init__(customer, pwd)
|
super().__init__(customer, pwd, connection_factory)
|
||||||
self.customer_name = customer + '_'
|
self.customer_name = self.customer_table
|
||||||
self.customer_pwd = (pwd*10)[:43]+"="
|
self.customer_pwd = self.db_password
|
||||||
|
self.db_username = DB_ADMIN_USER
|
||||||
|
self.db_password = DB_ADMIN_PASSWORD
|
||||||
|
|
||||||
self.db_username = 'flower'
|
def createNewTable(self) -> int:
|
||||||
self.db_password = '608f0b988db4a96066af7dd8870de96c'
|
statements = [
|
||||||
|
f"""CREATE TABLE {self._table} (
|
||||||
def createNewTable(self):
|
organization varchar(80) NOT NULL,
|
||||||
# create new table and user
|
myID varchar(255) NOT NULL,
|
||||||
sql = """
|
myName varchar(255) NOT NULL,
|
||||||
CREATE TABLE `{}` (
|
mySecret varchar(255) NOT NULL,
|
||||||
`organization` varchar(80) NOT NULL,
|
dateCreated timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`myID` varchar(255) NOT NULL,
|
deleted int(11) DEFAULT 0,
|
||||||
`myName` varchar(255) NOT NULL,
|
PRIMARY KEY (organization, dateCreated)
|
||||||
`mySecret` varchar(255) NOT NULL,
|
) ENGINE=InnoDB DEFAULT CHARSET=latin1""",
|
||||||
`dateCreated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
f"CREATE USER '{self.customer_name}'@'localhost' IDENTIFIED BY %s",
|
||||||
`deleted` int(11) DEFAULT '0',
|
f"GRANT SELECT, UPDATE, INSERT ON `{self.db}`.{self._table} TO '{self.customer_name}'@'localhost'",
|
||||||
PRIMARY KEY (`organization`,`dateCreated`)
|
]
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=latin1;""".format(self.customer_table)
|
try:
|
||||||
result, data = self.do_db_1row(sql)
|
with self._connection() as connection:
|
||||||
if result < 0:
|
with connection.cursor() as cursor:
|
||||||
Log.info("SQL ERROR:")
|
cursor.execute(statements[0])
|
||||||
Log.info(data)
|
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
|
return 0
|
||||||
|
|
||||||
sql = """create user {}@localhost identified by '{}';""".format(self.customer_name, self.customer_pwd)
|
def flush_privs(self) -> int:
|
||||||
result, data = self.do_db_1row(sql)
|
try:
|
||||||
if result < 0:
|
self._execute_write("FLUSH PRIVILEGES", ())
|
||||||
Log.info("SQL ERROR:")
|
return 1
|
||||||
Log.info(data)
|
except StorageError:
|
||||||
return 0
|
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()
|
if __name__ == "__main__":
|
||||||
|
print("This module is a library.")
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
from sys import stderr
|
"""Compatibility logging facade."""
|
||||||
from Config import *
|
|
||||||
|
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:
|
class Log:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def info(someString):
|
def info(message) -> None:
|
||||||
if INFO:
|
_logger.info("%s", message)
|
||||||
f = open(LOGFILE, "a")
|
|
||||||
f.write( "flower: %s \n" % someString )
|
|
||||||
f.close()
|
|
||||||
if DEBUG:
|
|
||||||
print(someString)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def debug(someString):
|
def debug(message) -> None:
|
||||||
if DEBUG:
|
_logger.debug("%s", message)
|
||||||
f = open(LOGFILE, "a")
|
|
||||||
f.write( "flower: %s \n" % someString )
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,230 +1,142 @@
|
|||||||
# Flowers
|
# 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]
|
## Existing database compatibility
|
||||||
> 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.
|
|
||||||
|
|
||||||
## 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.
|
- the `Flowers` database;
|
||||||
- Encrypts the login name, login ID, and secret with Fernet before storing them.
|
- one database account and table named `<username>_` per vault;
|
||||||
- Lists the most recent active record for each organization.
|
- the existing `organization`, `myID`, `myName`, `mySecret`, `dateCreated`, and `deleted` columns;
|
||||||
- Supports searching, viewing, adding, and soft-deleting records.
|
- the historical password transformation `(password * 10)[:43] + "="` for both the database password and Fernet key;
|
||||||
- Keeps older records when a new version of an organization is added.
|
- Fernet ciphertext in the three protected columns;
|
||||||
- Locks an IP address out after three failed logins within five minutes.
|
- append-only edits, where the newest timestamp is the visible version; and
|
||||||
- Exposes the same core operations through `POST /app` for a companion client.
|
- `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
|
## What changed
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data model
|
- All stored values are passed to MariaDB as query parameters.
|
||||||
|
- Usernames are validated before being used as table/account identifiers.
|
||||||
Each application username maps to:
|
- Database and runtime settings can be supplied through environment variables.
|
||||||
|
- Browser forms have CSRF protection.
|
||||||
- a database account named `<username>_`;
|
- Database credentials are stored in server memory behind an opaque session token, not in Flask's signed browser cookie.
|
||||||
- a table named `<username>_` in the `Flowers` database; and
|
- Browser sessions expire after ten minutes of inactivity.
|
||||||
- a database password/Fernet key derived by repeating the application password and truncating it to 43 characters, then appending `=`.
|
- 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.
|
||||||
Each table contains:
|
- The Flask application factory and WSGI entry point both work.
|
||||||
|
- Compatibility tests cover legacy encryption, reads, writes, listing, and the old API format.
|
||||||
| 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.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3
|
- Python 3.10 or newer
|
||||||
- MariaDB or MySQL running on `localhost`
|
- MariaDB or MySQL on the configured host
|
||||||
- Python packages: Flask, Waitress, `fernet`, PyMySQL, and pytz
|
- the packages in `requirements.txt`
|
||||||
- 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
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
|
```sql
|
||||||
CREATE DATABASE Flowers;
|
CREATE DATABASE Flowers;
|
||||||
CREATE USER 'flower'@'localhost'
|
CREATE USER 'flower'@'localhost' IDENTIFIED BY 'replace-this';
|
||||||
IDENTIFIED BY '608f0b988db4a96066af7dd8870de96c';
|
|
||||||
GRANT CREATE, SELECT, UPDATE, INSERT ON Flowers.*
|
GRANT CREATE, SELECT, UPDATE, INSERT ON Flowers.*
|
||||||
TO 'flower'@'localhost' WITH GRANT OPTION;
|
TO 'flower'@'localhost' WITH GRANT OPTION;
|
||||||
GRANT CREATE USER, RELOAD ON *.* TO 'flower'@'localhost';
|
GRANT CREATE USER, RELOAD ON *.* TO 'flower'@'localhost';
|
||||||
FLUSH PRIVILEGES;
|
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`:
|
Development:
|
||||||
|
|
||||||
```python
|
|
||||||
URLPREFIX = '' # serve at http://127.0.0.1:5012/
|
|
||||||
# URLPREFIX = '/flowers' # serve below /flowers
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Start the server
|
|
||||||
|
|
||||||
For local development:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python flowers.py
|
python flowers.py
|
||||||
```
|
```
|
||||||
|
|
||||||
For a non-debug application server:
|
Production application server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
waitress-serve --threads=6 --host=127.0.0.1 --port=5012 --call flowers:create_app
|
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
|
The tests do not need a live database; they use the actual legacy Fernet implementation with a MariaDB-compatible fake connection.
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:5012/app \
|
pip install -r requirements-dev.txt
|
||||||
--data-urlencode 'action=login' \
|
python -m pytest
|
||||||
--data-urlencode 'name=demo' \
|
|
||||||
--data-urlencode 'password=replace-me'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A typical response is:
|
They can also run using only the standard library test runner:
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo a2enmod proxy proxy_http
|
python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
```apache
|
## Remaining security constraints
|
||||||
ProxyPass "/flowers" "http://127.0.0.1:5012/flowers"
|
|
||||||
ProxyPassReverse "/flowers" "http://127.0.0.1:5012/flowers"
|
|
||||||
```
|
|
||||||
|
|
||||||
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 |
|
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.
|
||||||
| --- | --- |
|
|
||||||
| `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.
|
|
||||||
|
|||||||
+310
-184
@@ -1,202 +1,328 @@
|
|||||||
from flask import Flask, Blueprint, send_from_directory
|
"""HTTP routes for the Flowers browser UI and legacy form API."""
|
||||||
from Config import *
|
|
||||||
import sys
|
from __future__ import annotations
|
||||||
import datetime
|
|
||||||
import pytz
|
|
||||||
from FlowerServices import Flower,SuperFlower
|
|
||||||
from AccessControl import Access
|
|
||||||
import json
|
import json
|
||||||
from flask import Flask, request, session, g, redirect, url_for, abort, render_template, flash, json, make_response
|
import secrets
|
||||||
from werkzeug.exceptions import HTTPException
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
# configuration
|
from flask import (
|
||||||
# DEBUG = False
|
Blueprint,
|
||||||
# SECRET_KEY = 'development key'
|
abort,
|
||||||
flower_bp = Blueprint("flower_vase", __name__, url_prefix=URLPREFIX)
|
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__)
|
flower_bp = Blueprint(
|
||||||
# app.config.from_object(__name__)
|
"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():
|
def index():
|
||||||
print(request.remote_addr)
|
return _login_page()
|
||||||
return render_template('index.html', name_suggestion="Hello", pwd_suggestion="Want to try your luck?")
|
|
||||||
|
|
||||||
@flower_bp.route('/browser' , methods=['POST', 'GET'])
|
|
||||||
|
@flower_bp.route("/browser", methods=["GET", "POST"])
|
||||||
def application():
|
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':
|
access = Access()
|
||||||
return render_template('index.html', name_suggestion="Faded out", pwd_suggestion="Want to retry your luck?")
|
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()
|
action = request.form.get("action", "")
|
||||||
if not access_ctrl.granted(request.remote_addr):
|
if action == "login":
|
||||||
return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Access denied")
|
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']
|
vault = _vault_from_session()
|
||||||
filter=''
|
if vault is None:
|
||||||
if 'filter' in session:
|
session.pop("auth_token", None)
|
||||||
filter = session['filter']
|
flash("Your session expired. Please sign in again.", "error")
|
||||||
|
return redirect(url_for("flower_vase.index"))
|
||||||
|
|
||||||
if action == 'logout':
|
selected_filter = session.get("filter", "")
|
||||||
session['filter'] = request.form['filter']
|
form_filter = request.form.get("filter", "")
|
||||||
session['timeout'] = now - datetime.timedelta(minutes=999)
|
if len(form_filter) > 1 and form_filter != DONOTSETFILTER:
|
||||||
return render_template('index.html', name_suggestion="Bye", pwd_suggestion="Come back any time")
|
selected_filter = form_filter
|
||||||
|
session["filter"] = selected_filter
|
||||||
|
|
||||||
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'})
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
access_ctrl = Access()
|
if action == "list":
|
||||||
if not access_ctrl.granted(request.remote_addr):
|
return render_template("list.html", flowers=vault.all(), filter=selected_filter)
|
||||||
result = json.dumps({'result': -1, 'message': 'Access denied'})
|
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:
|
abort(400, description="Unknown action")
|
||||||
#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
|
|
||||||
|
|
||||||
|
|
||||||
|
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 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 = 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)
|
app.register_blueprint(flower_bp)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
my_app = create_app()
|
create_app().run(debug=DEBUG, host="127.0.0.1", port=5012)
|
||||||
# if needed - initialize data
|
|
||||||
my_app.run(debug=True, host='127.0.0.1', port=5012)
|
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/python
|
#!/usr/bin/python
|
||||||
|
|
||||||
import sys
|
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 @@
|
|||||||
|
:root {
|
||||||
tr.flowerlist:hover td {
|
--bg: #0b1010;
|
||||||
background:#eee;
|
--panel: #131b1a;
|
||||||
cursor: pointer;
|
--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 {
|
.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); }
|
||||||
width: 100%;
|
.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 {
|
.page-shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: clamp(56px, 9vw, 112px) 0; min-height: calc(100vh - 150px); }
|
||||||
color: inherit; /* blue colors for links too */
|
footer { padding: 24px; color: #65716e; text-align: center; font-size: .8rem; }
|
||||||
text-decoration: inherit; /* no underline */
|
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 {
|
.auth-layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, 430px); align-items: center; gap: clamp(44px, 9vw, 120px); }
|
||||||
color: #eee
|
.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 {
|
.button, .icon-button { border: 0; cursor: pointer; }
|
||||||
text-align: left;
|
.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 {
|
.notices { position: fixed; z-index: 10; top: 88px; right: 24px; display: grid; gap: 8px; }
|
||||||
text-align: right;
|
.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 {
|
.vault-header { display: flex; justify-content: space-between; align-items: end; gap: 32px; margin-bottom: 38px; }
|
||||||
color: #208dd6;
|
.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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0" />
|
<title>{% block title %}Flowers{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('flower_vase.static', filename='flower.css') }}">
|
||||||
<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>
|
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
<div class="header">
|
<div class="notices" aria-live="polite">
|
||||||
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
|
{% for category, message in messages %}
|
||||||
<a class="pure-menu-heading" href="">Flowers</a>
|
<div class="notice notice-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
<ul class="pure-menu-list">
|
</div>
|
||||||
{% block menuoptions %}
|
{% endif %}
|
||||||
<input type="text" id="search" placeholder="search..." value="{{filter}}">
|
{% endwith %}
|
||||||
<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 %}
|
|
||||||
|
|
||||||
|
<main class="page-shell">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>Private by design. Stored in your existing Flowers vault.</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+21
-33
@@ -1,35 +1,23 @@
|
|||||||
{% extends "common.html" %}
|
{% extends "common.html" %}
|
||||||
|
{% block title %}Create a vault · Flowers{% endblock %}
|
||||||
{% block headandmenu %}
|
{% block menuoptions %}<a class="text-link" href="{{ url_for('flower_vase.index') }}">Sign in</a>{% endblock %}
|
||||||
<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 content %}
|
{% block content %}
|
||||||
<div class="splash-container">
|
<section class="auth-layout">
|
||||||
<div class="splash">
|
<div class="intro">
|
||||||
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.create') }}" method="post">
|
<p class="eyebrow">A vase of your own</p>
|
||||||
<fieldset>
|
<h1>Create a vault.</h1>
|
||||||
<div class="pure-control-group">
|
<p>This uses the existing Flowers database structure so it remains readable by compatible installations.</p>
|
||||||
<input maxlength="32" name="name" type="text" placeholder="make up your username">
|
</div>
|
||||||
</div>
|
<form class="card form-card" action="{{ url_for('flower_vase.create') }}" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="pure-control-group">
|
<h2>New vault</h2>
|
||||||
<input maxlength="10" name="password" type="password" placeholder="make up you password, the longer the better">
|
{% if message %}<p class="field-error">{{ message }}</p>{% endif %}
|
||||||
</div>
|
<label for="name">Username</label>
|
||||||
|
<input maxlength="31" id="name" name="name" type="text" pattern="[A-Za-z0-9_]+" autocomplete="username" required>
|
||||||
<div class="pure-controls">
|
<label for="password">Password</label>
|
||||||
<button type="submit" class="pure-button pure-button-primary">Create Vase</button>
|
<input maxlength="43" id="password" name="password" type="password" pattern="[A-Za-z0-9_+/-]+" autocomplete="new-password" required>
|
||||||
</div>
|
<p class="field-help">Use Base64-compatible characters for legacy encryption compatibility.</p>
|
||||||
</fieldset>
|
<button class="button primary" type="submit">Create vault</button>
|
||||||
</form>
|
</form>
|
||||||
{{ message }}
|
</section>
|
||||||
|
{% endblock %}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock content %}
|
|
||||||
|
|||||||
+38
-42
@@ -1,45 +1,41 @@
|
|||||||
{% extends "common.html" %}
|
{% extends "common.html" %}
|
||||||
|
{% block title %}{% if flower.organization %}Edit{% else %}New entry{% endif %} · Flowers{% endblock %}
|
||||||
{% block menuoptions %}
|
{% block menuoptions %}
|
||||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:$('#newFlower').submit()" class="pure-menu-link">Save</a></li>
|
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('deactivate','{{ flower.organization }}','{{ flower.dateCreated }}','')" class="pure-menu-link">Delete</a></li>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('list','','','')" class="pure-menu-link">Cancel</a></li>
|
<input type="hidden" name="action" value="list">
|
||||||
{% endblock menuoptions %}
|
<button class="button ghost compact" type="submit">Cancel</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="splash-container">
|
<section class="editor-layout">
|
||||||
<div class="splash">
|
<div>
|
||||||
<form class="pure-form pure-form-stacked" id="newFlower" action="{{ url_for('flower_vase.application') }}" method="post">
|
<p class="eyebrow">{% if flower.organization %}New version{% else %}New credential{% endif %}</p>
|
||||||
<fieldset>
|
<h1>{% if flower.organization %}Edit entry{% else %}Add an entry{% endif %}</h1>
|
||||||
<div class="pure-control-group">
|
<p class="muted">Saving creates a new version. Your historical rows remain untouched.</p>
|
||||||
<input maxlength="70" size="50" id="organization" name="organization" type="text" value="{{ flower.organization }}" placeholder="Organization">
|
</div>
|
||||||
</div>
|
<form class="card form-card wide" action="{{ url_for('flower_vase.application') }}" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="pure-control-group">
|
<input type="hidden" name="action" value="save">
|
||||||
<input maxlength="70" id="myname" name="myname" type="text" value="{{ flower.myName }}" placeholder="Your name (optional)">
|
<input type="hidden" name="filter" value="{{ filter|default('') }}">
|
||||||
</div>
|
<label for="organization">Organization</label>
|
||||||
|
<input maxlength="80" id="organization" name="organization" type="text" value="{{ flower.organization }}" autocomplete="organization" required>
|
||||||
<div class="pure-control-group">
|
<label for="myname">Your name <span>optional</span></label>
|
||||||
<input maxlength="70" id="myid" name="myid" type="text" value="{{ flower.myID }}" placeholder="Your Login">
|
<input maxlength="70" id="myname" name="myname" type="text" value="{{ flower.myName }}" autocomplete="name">
|
||||||
</div>
|
<label for="myid">Login</label>
|
||||||
|
<input maxlength="70" id="myid" name="myid" type="text" value="{{ flower.myID }}" autocomplete="username" required>
|
||||||
<div class="pure-control-group">
|
<label for="secret">Secret</label>
|
||||||
<input maxlength="30" id="secret" name="secret" type="text" placeholder="Your secret">
|
<input maxlength="120" id="secret" name="secret" type="password" autocomplete="new-password" required>
|
||||||
</div>
|
<button class="button primary" type="submit">Save new version</button>
|
||||||
<input type="hidden" id="action" name="action" value="save">
|
</form>
|
||||||
<input type="hidden" id="filter" name="filter" value="DoNotSetFilterinCookie">
|
{% 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?')">
|
||||||
</fieldset>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="deactivate">
|
||||||
</form>
|
<input type="hidden" name="organization" value="{{ flower.organization }}">
|
||||||
|
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
|
||||||
</div>
|
<button class="button danger" type="submit">Deactivate entry</button>
|
||||||
</div>
|
</form>
|
||||||
|
{% endif %}
|
||||||
<script language="javascript">
|
</section>
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+22
-35
@@ -1,37 +1,24 @@
|
|||||||
{% extends "common.html" %}
|
{% extends "common.html" %}
|
||||||
|
{% block title %}Sign in · Flowers{% endblock %}
|
||||||
{% block headandmenu %}
|
{% block menuoptions %}
|
||||||
<div class="header">
|
<a class="text-link" href="{{ url_for('flower_vase.create') }}">Create a vault</a>
|
||||||
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
|
{% endblock %}
|
||||||
<a class="pure-menu-heading" href="">Flowers</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock headandmenu %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="splash-container">
|
<section class="auth-layout">
|
||||||
<div class="splash">
|
<div class="intro">
|
||||||
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.application') }}" method="post">
|
<p class="eyebrow">Your private collection</p>
|
||||||
<fieldset>
|
<h1>Credentials, kept close.</h1>
|
||||||
<div class="pure-control-group">
|
<p>Open your existing vault. Your encrypted records stay in the same database and format.</p>
|
||||||
<input name="name" type="text" placeholder="{{ name_suggestion }}">
|
</div>
|
||||||
</div>
|
<form class="card form-card" action="{{ url_for('flower_vase.application') }}" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="pure-control-group">
|
<input type="hidden" name="action" value="login">
|
||||||
<input name="password" type="password" placeholder="{{ pwd_suggestion }}">
|
<h2>Sign in</h2>
|
||||||
</div>
|
<label for="name">Username</label>
|
||||||
|
<input id="name" name="name" type="text" autocomplete="username" placeholder="{{ name_suggestion }}" required autofocus>
|
||||||
<div class="pure-controls">
|
<label for="password">Password</label>
|
||||||
<button type="submit" class="pure-button pure-button-primary">Submit</button>
|
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="{{ pwd_suggestion }}" required>
|
||||||
</div>
|
<button class="button primary" type="submit">Open vault</button>
|
||||||
</fieldset>
|
</form>
|
||||||
<input type="hidden" id="action" name="action" value="login">
|
</section>
|
||||||
<input type="hidden" id="filter" name="filter" value="DoNotSetFilterinCookie">
|
{% endblock %}
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock content %}
|
|
||||||
|
|||||||
+72
-45
@@ -1,51 +1,78 @@
|
|||||||
{% extends "common.html" %}
|
{% 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 %}
|
{% 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="entry-list" id="entry-list">
|
||||||
<div class="content">
|
{% for flower in flowers %}
|
||||||
<div class="pure-g">
|
<form class="entry-row" data-search="{{ flower.organization }} {{ flower.myID }}" action="{{ url_for('flower_vase.application') }}" method="post">
|
||||||
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-5">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="show">
|
||||||
</div>
|
<input type="hidden" name="organization" value="{{ flower.organization }}">
|
||||||
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-3-5">
|
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
|
||||||
<table id="flowerlist" class="flowerlist pure-table pure-table-horizontal">
|
<input type="hidden" name="filter" class="current-filter" value="{{ filter|default('') }}">
|
||||||
{% for flower in flowers %}
|
<button type="submit" class="entry-button">
|
||||||
<tr class="flowerlist">
|
<span class="entry-icon" aria-hidden="true">{{ flower.organization[:1]|upper }}</span>
|
||||||
<td class="flowerlist">
|
<span class="entry-copy">
|
||||||
<a href="javascript:DoSubmit('show', '{{flower.organization}}', '{{flower.dateCreated}}' , '')">
|
<strong>{{ flower.organization }}</strong>
|
||||||
{{ flower.organization }}, {{ flower.myID }}
|
<span>{{ flower.myID }}</span>
|
||||||
</a>
|
</span>
|
||||||
</td>
|
<time datetime="{{ flower.dateCreated }}">{{ flower.dateCreated[:10] }}</time>
|
||||||
<td class="right"><span style="white-space: nowrap;">{{ flower.dateCreated[:10] }}</span></td>
|
<span class="chevron" aria-hidden="true">›</span>
|
||||||
</tr>
|
</button>
|
||||||
{% endfor %}
|
</form>
|
||||||
</table>
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
</div>
|
<span aria-hidden="true">✣</span>
|
||||||
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-5">
|
<h2>No active entries</h2>
|
||||||
|
<p>Create an entry to start your collection.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="empty-state filtered-empty" id="filtered-empty" hidden>No entries match that search.</p>
|
||||||
|
|
||||||
<script language="javascript">
|
<script>
|
||||||
|
const search = document.querySelector('#search');
|
||||||
var $rows = $('#flowerlist tr');
|
const rows = [...document.querySelectorAll('.entry-row')];
|
||||||
$('#search').keyup(function() {
|
const filteredEmpty = document.querySelector('#filtered-empty');
|
||||||
var val = $.trim($(this).val()).replace(/ +/g, ' ').toLowerCase();
|
function filterRows() {
|
||||||
|
const query = search.value.trim().toLocaleLowerCase();
|
||||||
$rows.show().filter(function() {
|
let visible = 0;
|
||||||
var text = $(this).text().replace(/\s+/g, ' ').toLowerCase();
|
rows.forEach((row) => {
|
||||||
return !~text.indexOf(val);
|
const match = row.dataset.search.toLocaleLowerCase().includes(query);
|
||||||
}).hide();
|
row.hidden = !match;
|
||||||
});
|
row.querySelector('.current-filter').value = search.value;
|
||||||
|
if (match) visible += 1;
|
||||||
$('#search').keyup();
|
});
|
||||||
|
filteredEmpty.hidden = visible !== 0 || rows.length === 0;
|
||||||
|
}
|
||||||
|
search.addEventListener('input', filterRows);
|
||||||
|
filterRows();
|
||||||
</script>
|
</script>
|
||||||
|
{% endblock %}
|
||||||
{% endblock content %}
|
|
||||||
|
|||||||
+22
-38
@@ -1,40 +1,24 @@
|
|||||||
{% extends "common.html" %}
|
{% extends "common.html" %}
|
||||||
|
{% block title %}Change password · Flowers{% endblock %}
|
||||||
{% block headandmenu %}
|
{% block menuoptions %}<a class="text-link" href="{{ url_for('flower_vase.index') }}">Back to sign in</a>{% endblock %}
|
||||||
<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 content %}
|
{% block content %}
|
||||||
<div class="splash-container">
|
<section class="auth-layout">
|
||||||
<div class="splash">
|
<div class="intro">
|
||||||
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.update_pwd') }}" method="post">
|
<p class="eyebrow">Re-encrypt your vault</p>
|
||||||
<fieldset>
|
<h1>Change password.</h1>
|
||||||
<div class="pure-control-group">
|
<p>Every existing encrypted field will be rewritten with the new legacy-compatible key.</p>
|
||||||
<input name="name" type="text" placeholder="your existing name">
|
</div>
|
||||||
</div>
|
<form class="card form-card" action="{{ url_for('flower_vase.update_pwd') }}" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="pure-control-group">
|
<h2>Vault credentials</h2>
|
||||||
<input name="old_password" type="password" placeholder="your existing password">
|
{% if message %}<p class="field-error">{{ message }}</p>{% endif %}
|
||||||
</div>
|
<label for="name">Username</label>
|
||||||
|
<input id="name" name="name" type="text" autocomplete="username" required>
|
||||||
<div class="pure-control-group">
|
<label for="old-password">Current password</label>
|
||||||
<input maxlength="10" name="new_password" type="password" placeholder="make up you password, the longer the better">
|
<input id="old-password" name="old_password" type="password" autocomplete="current-password" required>
|
||||||
</div>
|
<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>
|
||||||
<div class="pure-controls">
|
</form>
|
||||||
<button type="submit" class="pure-button pure-button-primary">Update Password</button>
|
</section>
|
||||||
</div>
|
{% endblock %}
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
{{ message }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock content %}
|
|
||||||
|
|||||||
+58
-44
@@ -1,47 +1,61 @@
|
|||||||
{% extends "common.html" %}
|
{% extends "common.html" %}
|
||||||
|
{% block title %}{{ flower.organization }} · Flowers{% endblock %}
|
||||||
{% block menuoptions %}
|
{% block menuoptions %}
|
||||||
<li class="pure-menu-item pure-menu-selected"><a href="javascript:editFlower()" class="pure-menu-link">Edit</a></li>
|
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||||
{% endblock menuoptions %}
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="list">
|
||||||
|
<input type="hidden" name="filter" value="{{ filter|default('') }}">
|
||||||
{% block content %}
|
<button class="button ghost compact" type="submit">Back to vault</button>
|
||||||
<div class="splash-container">
|
</form>
|
||||||
<div class="splash">
|
{% endblock %}
|
||||||
<table class="pure-table" style="width: 100%">
|
{% block content %}
|
||||||
<tr><td class="white right">Org</td><td class="white left">{{ flower.organization }}</td> </tr>
|
<section class="detail-card card">
|
||||||
<tr><td class="white right">You</td><td class="white left">{{ flower.myName }}</td> </tr>
|
<div class="detail-heading">
|
||||||
<tr><td class="white right">Login</td><td class="white left">{{ flower.myID }}</td> </tr>
|
<span class="entry-icon large" aria-hidden="true">{{ flower.organization[:1]|upper }}</span>
|
||||||
<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>
|
<div>
|
||||||
<tr><td class="white">{{ flower.dateCreated[:10] }}</td><td></td> </tr>
|
<p class="eyebrow">Credential</p>
|
||||||
</table>
|
<h1>{{ flower.organization }}</h1>
|
||||||
|
{% if flower.dateCreated not in ('STORED', 'FAILED') %}<p class="muted">Version from {{ flower.dateCreated[:10] }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="details">
|
||||||
<script language="javascript">
|
<div><dt>Name</dt><dd>{{ flower.myName or '—' }}</dd></div>
|
||||||
|
<div><dt>Login</dt><dd>{{ flower.myID }}</dd></div>
|
||||||
setTimeout(function(){
|
<div>
|
||||||
DoSubmit('list', '{{flower.organization}}', '{{flower.dateCreated}}', 'DoNotSetFilterinCookie')
|
<dt>Secret</dt>
|
||||||
}, 5000);
|
<dd class="secret-line">
|
||||||
|
<span id="secret" data-secret="{{ flower.mySecret }}">••••••••••••</span>
|
||||||
function editFlower() {
|
<button class="icon-button" id="reveal" type="button">Reveal</button>
|
||||||
DoSubmit('edit', '{{flower.organization}}', '{{flower.dateCreated}}', 'DoNotSetFilterinCookie')
|
<button class="icon-button" id="copy" type="button">Copy</button>
|
||||||
}
|
</dd>
|
||||||
|
</div>
|
||||||
function myCopyFunction() {
|
</dl>
|
||||||
var copyText = document.getElementById("secret");
|
{% if flower.dateCreated not in ('STORED', 'FAILED') %}
|
||||||
|
<div class="detail-actions">
|
||||||
// Copy the text inside the text field
|
<form action="{{ url_for('flower_vase.application') }}" method="post">
|
||||||
navigator.clipboard.writeText(copyText.getAttribute("value"));
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="action" value="edit">
|
||||||
// alert("Copied the text: "+copyText.getAttribute("value"));
|
<input type="hidden" name="organization" value="{{ flower.organization }}">
|
||||||
|
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
|
||||||
$("#secrettd").removeClass("white");
|
<button class="button primary" type="submit">Edit entry</button>
|
||||||
$("#secrettd").addClass("blue");
|
</form>
|
||||||
}
|
</div>
|
||||||
|
{% endif %}
|
||||||
</script>
|
</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 %}
|
{% 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