rewritten after codex recommendations
This commit is contained in:
+303
-245
@@ -1,269 +1,327 @@
|
||||
from fernet import Fernet
|
||||
from Log import Log
|
||||
from Config import *
|
||||
"""Database and encryption services for the legacy Flowers data format.
|
||||
|
||||
The public ``Flower`` API intentionally remains compatible with the original
|
||||
application. Existing tables and Fernet ciphertext require no migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from typing import Callable, Iterator
|
||||
|
||||
import pymysql as mdb
|
||||
from fernet import Fernet
|
||||
|
||||
#encryption stuff
|
||||
SECRET = b'XBxB603cX_mULEXxfavOg3FDc0Ox3gChwYEY-Uxd3tE='
|
||||
from Config import (
|
||||
DB_ADMIN_PASSWORD,
|
||||
DB_ADMIN_USER,
|
||||
DB_HOST,
|
||||
DB_NAME,
|
||||
DB_PORT,
|
||||
MINFIELDLEN,
|
||||
)
|
||||
from Log import Log
|
||||
|
||||
|
||||
class Flower():
|
||||
_USERNAME = re.compile(r"^[A-Za-z0-9_]{1,31}$")
|
||||
|
||||
def __init__(self, user, pwd):
|
||||
self.db = 'Flowers'
|
||||
self.db_username = user + '_'
|
||||
self.db_password = (pwd*10)[:43]+"="
|
||||
|
||||
class InvalidCredentials(ValueError):
|
||||
"""The supplied username/password cannot address a Flowers vault."""
|
||||
|
||||
|
||||
class StorageError(RuntimeError):
|
||||
"""A database operation failed."""
|
||||
|
||||
|
||||
def legacy_key(password: str) -> str:
|
||||
"""Return the exact DB password/Fernet key used by historical releases."""
|
||||
if not isinstance(password, str) or not password:
|
||||
raise InvalidCredentials("A password is required")
|
||||
key = (password * 10)[:43] + "="
|
||||
try:
|
||||
Fernet(key.encode("ascii"))
|
||||
except (UnicodeEncodeError, ValueError) as exc:
|
||||
raise InvalidCredentials(
|
||||
"Password must use Base64-compatible characters"
|
||||
) from exc
|
||||
return key
|
||||
|
||||
|
||||
def _identifier(username: str) -> str:
|
||||
if not isinstance(username, str) or not _USERNAME.fullmatch(username):
|
||||
raise InvalidCredentials(
|
||||
"Username must contain only letters, numbers, and underscores"
|
||||
)
|
||||
return f"{username}_"
|
||||
|
||||
|
||||
class Flower:
|
||||
"""Read and write one user's existing Flowers table."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user: str,
|
||||
pwd: str,
|
||||
connection_factory: Callable[..., object] | None = None,
|
||||
) -> None:
|
||||
self.db = DB_NAME
|
||||
self.db_username = _identifier(user)
|
||||
self.db_password = legacy_key(pwd)
|
||||
self.customer_table = self.db_username
|
||||
self.fernet = Fernet(bytes(self.db_password, 'ascii'))
|
||||
self._table = f"`{self.customer_table}`"
|
||||
self.fernet = Fernet(self.db_password.encode("ascii"))
|
||||
self._connect = connection_factory or mdb.connect
|
||||
|
||||
def encode(self, s):
|
||||
if len(s)==0:
|
||||
return ''
|
||||
return self.fernet.encrypt(s.encode()).decode()
|
||||
|
||||
def decode(self, s):
|
||||
if len(s)==0:
|
||||
return ''
|
||||
return self.fernet.decrypt(s.encode()).decode()
|
||||
|
||||
def add(self, organization, myname, myid, secret, deleted=0, timestamp=None):
|
||||
''' POST a new flower
|
||||
'''
|
||||
if len(organization)>MINFIELDLEN and len(myid)>MINFIELDLEN and len(secret)>MINFIELDLEN:
|
||||
sql = 'INSERT INTO {} (organization ,myID ,myName ,mySecret, deleted) VALUES ("{}", "{}", "{}", "{}", {})'.format(self.customer_table, organization, self.encode(myid), self.encode(myname), self.encode(secret), deleted)
|
||||
if timestamp is not None:
|
||||
sql = 'INSERT INTO {} (organization ,myID ,myName ,mySecret, deleted, dateCreated) VALUES ("{}", "{}", "{}", "{}", {}, "{}")'.format(self.customer_table, organization, self.encode(myid), self.encode(myname), self.encode(secret), deleted, timestamp)
|
||||
result, data = self.do_db_commit(sql)
|
||||
Log.debug(result)
|
||||
if result==1:
|
||||
return {'organization': organization, 'dateCreated': 'STORED', 'myID': myid, 'myName': myname,'mySecret': secret}
|
||||
return {'organization': organization, 'dateCreated': 'FAILED', 'myID': myid, 'myName': myname,'mySecret': secret}
|
||||
|
||||
|
||||
def all(self, isdeleted=0):
|
||||
''' GET flowers list
|
||||
'''
|
||||
#sql = "SELECT organization, myID, myName, dateCreated FROM %s " % (self.customer_table)
|
||||
sql = """
|
||||
select uniqueflowers.organization, uniqueflowers.myID, uniqueflowers.dateCreated from {0} as uniqueflowers
|
||||
inner join (
|
||||
select organization, max(dateCreated) as lastCreated from {0} group by organization order by dateCreated DESC) allflowers
|
||||
on allflowers.organization=uniqueflowers.organization and lastCreated=uniqueflowers.dateCreated where uniqueflowers.deleted={1}
|
||||
""".format(self.customer_table, isdeleted)
|
||||
result, data = self.do_db_allrows(sql)
|
||||
#Log.debug(sql_result)
|
||||
all_flowers=[]
|
||||
if result==1:
|
||||
for row in data:
|
||||
all_flowers.append({'organization': str(row[0]), 'myID': self.decode(row[1]), 'dateCreated': str(row[2])})
|
||||
return all_flowers
|
||||
|
||||
|
||||
|
||||
def one(self, organization, datetimestamp ):
|
||||
''' flower for user, org and datetime
|
||||
'''
|
||||
result = -1
|
||||
secret = ':-('
|
||||
sql = "SELECT mySecret, myID, myName FROM %s where organization='%s' and dateCreated='%s' order by dateCreated DESC limit 1" % (self.customer_table, organization, datetimestamp)
|
||||
result, data = self.do_db_1row(sql)
|
||||
if result == 1:
|
||||
return {'organization': organization, 'dateCreated': datetimestamp, 'myID': self.decode(data[1]), 'myName': self.decode(data[2]),'mySecret': self.decode(data[0])}
|
||||
return {'organization': organization, 'dateCreated': datetimestamp, 'myID': '', 'myName': '','mySecret': '-- Not found --'}
|
||||
|
||||
|
||||
def deactivate(self, organization, datetimestamp ):
|
||||
''' flower for user, org and datetime
|
||||
'''
|
||||
result = -1
|
||||
secret = ':-('
|
||||
sql = "update {} set deleted=1 where organization='{}' and dateCreated='{}' ".format(self.customer_table, organization, datetimestamp)
|
||||
result, data = self.do_db_commit(sql)
|
||||
if result == 1:
|
||||
secret = '- DELETED -'
|
||||
return {'organization': organization, 'dateCreated': datetimestamp, 'myID': '', 'myName': '','mySecret': secret}
|
||||
|
||||
def empty(self):
|
||||
''' empty flower
|
||||
'''
|
||||
return {'organization': '', 'dateCreated': '', 'myID': '', 'myName': '','mySecret': ''}
|
||||
|
||||
|
||||
def numberOfEntries(self):
|
||||
''' GET login info, check if its valid
|
||||
'''
|
||||
sql = "SELECT count(*) FROM {};".format(self.customer_table)
|
||||
result, data = self.do_db_1row(sql)
|
||||
if result == 1:
|
||||
return(data[0])
|
||||
return -1
|
||||
|
||||
|
||||
def update_pwd(self, new_pwd):
|
||||
|
||||
long_newpwd = (new_pwd*10)[:43]+"="
|
||||
self.new_fernet = Fernet(bytes(long_newpwd, 'ascii'))
|
||||
|
||||
def new_encode(s):
|
||||
if len(s)==0:
|
||||
return ''
|
||||
return self.new_fernet.encrypt(s.encode()).decode()
|
||||
|
||||
end_result = "ERROR updating hushes\n"
|
||||
sql = """
|
||||
select myID, myName, mySecret, organization, dateCreated from {0}""".format(self.customer_table)
|
||||
result, data = self.do_db_allrows(sql)
|
||||
# Log.debug(sql_result)
|
||||
if result == 1:
|
||||
# convert all hushes
|
||||
sql = ""
|
||||
for row in data:
|
||||
sql += "update {} set myID='{}', myName='{}', mySecret='{}' where organization='{}' and dateCreated='{}';\n".\
|
||||
format(self.customer_table,
|
||||
new_encode(self.decode(row[0])),
|
||||
new_encode(self.decode(row[1])),
|
||||
new_encode(self.decode(row[2])),row[3], row[4])
|
||||
|
||||
result, data = self.do_db_commit(sql)
|
||||
if result == 1:
|
||||
end_result = "ERROR updating user-password\n"
|
||||
# update the password of the user
|
||||
sql = "SET PASSWORD FOR '{}'@'localhost' = PASSWORD('{}'); ".format(self.db_username, long_newpwd)
|
||||
result, data = self.do_db_commit(sql)
|
||||
if result == 1:
|
||||
end_result = "SUCCESSFULLY updated, now login again\n"
|
||||
return end_result
|
||||
|
||||
|
||||
def do_db_commit(self, sql):
|
||||
Log.debug('INSc: '+sql)
|
||||
result = 0
|
||||
data = ''
|
||||
con = None
|
||||
@contextmanager
|
||||
def _connection(self) -> Iterator[object]:
|
||||
connection = None
|
||||
try:
|
||||
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
|
||||
cur = con.cursor()
|
||||
if ";\n" in sql:
|
||||
for s in sql.split(";\n"):
|
||||
if len(s)>1:
|
||||
cur.execute(s)
|
||||
else:
|
||||
cur.execute(sql)
|
||||
cur.close()
|
||||
con.commit()
|
||||
result = 1
|
||||
except mdb.Error as e:
|
||||
m = "Error %d: %s" % (e.args[0],e.args[1])
|
||||
data = {'message': m}
|
||||
Log.debug(m)
|
||||
result = -1
|
||||
connection = self._connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=self.db_username,
|
||||
passwd=self.db_password,
|
||||
db=self.db,
|
||||
charset="latin1",
|
||||
)
|
||||
yield connection
|
||||
except mdb.Error as exc:
|
||||
Log.debug(f"Database error: {exc}")
|
||||
raise StorageError("The vault database is unavailable") from exc
|
||||
finally:
|
||||
if con:
|
||||
con.close()
|
||||
return result, data
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
|
||||
def encode(self, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return self.fernet.encrypt(value.encode("utf-8")).decode("ascii")
|
||||
|
||||
def do_db_1row(self, sql):
|
||||
Log.debug('1row sql: '+sql)
|
||||
result = 0
|
||||
con = None
|
||||
def decode(self, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return self.fernet.decrypt(value.encode("ascii")).decode("utf-8")
|
||||
|
||||
def authenticate(self) -> bool:
|
||||
try:
|
||||
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
|
||||
cur = con.cursor()
|
||||
cur.execute(sql)
|
||||
data = cur.fetchone()
|
||||
if data:
|
||||
result = 1
|
||||
except mdb.Error as e:
|
||||
m = "Error %d: %s" % (e.args[0],e.args[1])
|
||||
data = {'message': m}
|
||||
Log.debug(m)
|
||||
result = -1
|
||||
finally:
|
||||
if con:
|
||||
con.close()
|
||||
return result, data
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {self._table}")
|
||||
cursor.fetchone()
|
||||
return True
|
||||
except (StorageError, ValueError):
|
||||
return False
|
||||
|
||||
def do_db_allrows(self, sql):
|
||||
Log.debug('all rows sql: '+sql)
|
||||
result = 0
|
||||
con = None
|
||||
def numberOfEntries(self) -> int:
|
||||
"""Compatibility method used by older clients and scripts."""
|
||||
try:
|
||||
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
|
||||
cur = con.cursor()
|
||||
cur.execute(sql)
|
||||
data = cur.fetchall()
|
||||
if data:
|
||||
result = 1
|
||||
except mdb.Error as e:
|
||||
m = "Error %d: %s" % (e.args[0],e.args[1])
|
||||
data = {'message': m}
|
||||
Log.debug(m)
|
||||
result = -1
|
||||
finally:
|
||||
if con:
|
||||
con.close()
|
||||
return result, data
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {self._table}")
|
||||
row = cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
except StorageError:
|
||||
return -1
|
||||
|
||||
def add(
|
||||
self,
|
||||
organization: str,
|
||||
myname: str,
|
||||
myid: str,
|
||||
secret: str,
|
||||
deleted: int = 0,
|
||||
timestamp=None,
|
||||
) -> dict:
|
||||
item = self._item(organization, "FAILED", myid, myname, secret)
|
||||
if not all(isinstance(v, str) for v in (organization, myname, myid, secret)):
|
||||
return item
|
||||
if not (
|
||||
len(organization) > MINFIELDLEN
|
||||
and len(myid) > MINFIELDLEN
|
||||
and len(secret) > MINFIELDLEN
|
||||
):
|
||||
return item
|
||||
|
||||
columns = "organization, myID, myName, mySecret, deleted"
|
||||
values = [
|
||||
organization,
|
||||
self.encode(myid),
|
||||
self.encode(myname),
|
||||
self.encode(secret),
|
||||
int(bool(deleted)),
|
||||
]
|
||||
placeholders = "%s, %s, %s, %s, %s"
|
||||
if timestamp is not None:
|
||||
columns += ", dateCreated"
|
||||
placeholders += ", %s"
|
||||
values.append(timestamp)
|
||||
|
||||
sql = f"INSERT INTO {self._table} ({columns}) VALUES ({placeholders})"
|
||||
self._execute_write(sql, values)
|
||||
return self._item(organization, "STORED", myid, myname, secret)
|
||||
|
||||
def all(self, isdeleted: int = 0) -> list[dict]:
|
||||
sql = f"""
|
||||
SELECT latest.organization, latest.myID, latest.dateCreated
|
||||
FROM {self._table} AS latest
|
||||
INNER JOIN (
|
||||
SELECT organization, MAX(dateCreated) AS lastCreated
|
||||
FROM {self._table}
|
||||
GROUP BY organization
|
||||
) AS versions
|
||||
ON versions.organization = latest.organization
|
||||
AND versions.lastCreated = latest.dateCreated
|
||||
WHERE latest.deleted = %s
|
||||
ORDER BY latest.dateCreated DESC, latest.organization ASC
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, (int(bool(isdeleted)),))
|
||||
rows = cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"organization": str(row[0]),
|
||||
"myID": self.decode(row[1]),
|
||||
"dateCreated": str(row[2]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def one(self, organization: str, datetimestamp) -> dict:
|
||||
sql = f"""
|
||||
SELECT mySecret, myID, myName
|
||||
FROM {self._table}
|
||||
WHERE organization = %s AND dateCreated = %s
|
||||
ORDER BY dateCreated DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, (organization, datetimestamp))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return self._item(
|
||||
organization,
|
||||
str(datetimestamp),
|
||||
self.decode(row[1]),
|
||||
self.decode(row[2]),
|
||||
self.decode(row[0]),
|
||||
)
|
||||
return self._item(organization, str(datetimestamp), "", "", "-- Not found --")
|
||||
|
||||
def deactivate(self, organization: str, datetimestamp) -> dict:
|
||||
sql = f"UPDATE {self._table} SET deleted = 1 WHERE organization = %s AND dateCreated = %s"
|
||||
changed = self._execute_write(sql, (organization, datetimestamp))
|
||||
message = "- DELETED -" if changed else "-- Not found --"
|
||||
return self._item(organization, str(datetimestamp), "", "", message)
|
||||
|
||||
def empty(self) -> dict:
|
||||
return self._item("", "", "", "", "")
|
||||
|
||||
def update_pwd(self, new_pwd: str) -> str:
|
||||
"""Re-encrypt every value and update the legacy per-user DB password."""
|
||||
new_db_password = legacy_key(new_pwd)
|
||||
new_fernet = Fernet(new_db_password.encode("ascii"))
|
||||
|
||||
try:
|
||||
with self._connection() as connection:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"SELECT myID, myName, mySecret, organization, dateCreated FROM {self._table}"
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
update = f"""
|
||||
UPDATE {self._table}
|
||||
SET myID = %s, myName = %s, mySecret = %s
|
||||
WHERE organization = %s AND dateCreated = %s
|
||||
"""
|
||||
for row in rows:
|
||||
encrypted = [
|
||||
new_fernet.encrypt(self.decode(value).encode("utf-8")).decode("ascii")
|
||||
if value else ""
|
||||
for value in row[:3]
|
||||
]
|
||||
cursor.execute(update, (*encrypted, row[3], row[4]))
|
||||
# This form is supported by the MariaDB versions used by
|
||||
# historical Flowers installations.
|
||||
cursor.execute("SET PASSWORD = PASSWORD(%s)", (new_db_password,))
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
Log.debug(f"Password update failed: {exc}")
|
||||
return "ERROR updating password"
|
||||
return "SUCCESSFULLY updated, now login again"
|
||||
|
||||
def _execute_write(self, sql: str, parameters) -> int:
|
||||
with self._connection() as connection:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
changed = cursor.execute(sql, parameters)
|
||||
connection.commit()
|
||||
return int(changed)
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _item(organization, timestamp, myid, myname, secret) -> dict:
|
||||
return {
|
||||
"organization": organization,
|
||||
"dateCreated": timestamp,
|
||||
"myID": myid,
|
||||
"myName": myname,
|
||||
"mySecret": secret,
|
||||
}
|
||||
|
||||
|
||||
class SuperFlower(Flower):
|
||||
"""Provision a legacy per-user table and database account."""
|
||||
|
||||
def __init__(self, customer, pwd):
|
||||
super().__init__(customer, pwd)
|
||||
self.customer_name = customer + '_'
|
||||
self.customer_pwd = (pwd*10)[:43]+"="
|
||||
def __init__(self, customer: str, pwd: str, connection_factory=None) -> None:
|
||||
super().__init__(customer, pwd, connection_factory)
|
||||
self.customer_name = self.customer_table
|
||||
self.customer_pwd = self.db_password
|
||||
self.db_username = DB_ADMIN_USER
|
||||
self.db_password = DB_ADMIN_PASSWORD
|
||||
|
||||
self.db_username = 'flower'
|
||||
self.db_password = '608f0b988db4a96066af7dd8870de96c'
|
||||
|
||||
def createNewTable(self):
|
||||
# create new table and user
|
||||
sql = """
|
||||
CREATE TABLE `{}` (
|
||||
`organization` varchar(80) NOT NULL,
|
||||
`myID` varchar(255) NOT NULL,
|
||||
`myName` varchar(255) NOT NULL,
|
||||
`mySecret` varchar(255) NOT NULL,
|
||||
`dateCreated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`deleted` int(11) DEFAULT '0',
|
||||
PRIMARY KEY (`organization`,`dateCreated`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=latin1;""".format(self.customer_table)
|
||||
result, data = self.do_db_1row(sql)
|
||||
if result < 0:
|
||||
Log.info("SQL ERROR:")
|
||||
Log.info(data)
|
||||
def createNewTable(self) -> int:
|
||||
statements = [
|
||||
f"""CREATE TABLE {self._table} (
|
||||
organization varchar(80) NOT NULL,
|
||||
myID varchar(255) NOT NULL,
|
||||
myName varchar(255) NOT NULL,
|
||||
mySecret varchar(255) NOT NULL,
|
||||
dateCreated timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted int(11) DEFAULT 0,
|
||||
PRIMARY KEY (organization, dateCreated)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=latin1""",
|
||||
f"CREATE USER '{self.customer_name}'@'localhost' IDENTIFIED BY %s",
|
||||
f"GRANT SELECT, UPDATE, INSERT ON `{self.db}`.{self._table} TO '{self.customer_name}'@'localhost'",
|
||||
]
|
||||
try:
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(statements[0])
|
||||
cursor.execute(statements[1], (self.customer_pwd,))
|
||||
cursor.execute(statements[2])
|
||||
connection.commit()
|
||||
return 1
|
||||
except StorageError as exc:
|
||||
Log.info(f"Could not create vault: {exc}")
|
||||
return 0
|
||||
|
||||
sql = """create user {}@localhost identified by '{}';""".format(self.customer_name, self.customer_pwd)
|
||||
result, data = self.do_db_1row(sql)
|
||||
if result < 0:
|
||||
Log.info("SQL ERROR:")
|
||||
Log.info(data)
|
||||
def flush_privs(self) -> int:
|
||||
try:
|
||||
self._execute_write("FLUSH PRIVILEGES", ())
|
||||
return 1
|
||||
except StorageError:
|
||||
return 0
|
||||
|
||||
sql = """grant select,update,insert on Flowers.{} to {}@localhost;""".format(self.customer_table, self.customer_name)
|
||||
result, data = self.do_db_1row(sql)
|
||||
if result < 0:
|
||||
Log.info("SQL ERROR:")
|
||||
Log.info(data)
|
||||
return 0
|
||||
|
||||
self.flush_privs()
|
||||
|
||||
return 1
|
||||
|
||||
def flush_privs(self):
|
||||
sql = """flush privileges;"""
|
||||
result, data = self.do_db_1row(sql)
|
||||
if result < 0:
|
||||
Log.info("SQL ERROR:")
|
||||
Log.info(data)
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("this is a library, sorry dude")
|
||||
if __name__ == "__main__":
|
||||
print("This module is a library.")
|
||||
|
||||
Reference in New Issue
Block a user