From 15770f28798d9ead9e89dfab89f47ea40b91d45e Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Wed, 28 May 2025 10:16:13 -0400 Subject: [PATCH] add user database code --- .gitignore | 2 + README.md | 11 ++ docker-compose.yml | 4 + pyproject.toml | 16 +- roc_fnb/util/__init__.py | 0 roc_fnb/util/base64.py | 30 ++++ roc_fnb/util/test_base64.py | 11 ++ roc_fnb/website/database.py | 71 +++++++++ roc_fnb/website/models/__init__.py | 0 roc_fnb/website/models/test_user.py | 85 ++++++++++ roc_fnb/website/models/user.py | 90 +++++++++++ rocfnb.org_donate_qr.png | Bin 316 -> 0 bytes rocfnb.org_donate_qr.svg | 233 ---------------------------- 13 files changed, 319 insertions(+), 234 deletions(-) create mode 100644 README.md create mode 100644 roc_fnb/util/__init__.py create mode 100644 roc_fnb/util/base64.py create mode 100644 roc_fnb/util/test_base64.py create mode 100644 roc_fnb/website/database.py create mode 100644 roc_fnb/website/models/__init__.py create mode 100644 roc_fnb/website/models/test_user.py create mode 100644 roc_fnb/website/models/user.py delete mode 100644 rocfnb.org_donate_qr.png delete mode 100644 rocfnb.org_donate_qr.svg diff --git a/.gitignore b/.gitignore index 6e3af5b..0a93a67 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ build/ **/*.egg-info **/__pycache__ +mounts/ +**/*.pem \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd0369c --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ + +# Development + +## JWT Key pair generation + +JWT generation uses an RSA public-private key pair. To generate these keys run: + +```console +$ openssl genrsa -out private-key.pem 4096 +$ openssl rsa -in private-key.pem -pubout > public-key.pem +``` diff --git a/docker-compose.yml b/docker-compose.yml index e456d4a..ed7771c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,3 +13,7 @@ services: - type: bind source: ./mounts/database target: /data/db + +networks: + fnb-website: + internal: true \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 693bd80..dbad4db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,22 @@ description = "Temporary placeholder for fnb web site" readme = "README.md" requires-python = ">=3.11" # Self type added license = "AGPL-3.0-only" -dependencies = ["flask", "gunicorn"] +dependencies = [ + "flask", + "gunicorn", + "pymongo", + "scrypt", + "pyjwt", + + # For tests + "pytest" +] dynamic = ["version"] +[tool.setuptools] +# This is here because the top-level mounts directory gets interpreted by +# setuptools to be a module without it. +packages = ["roc_fnb"] + [project.scripts] # Put scripts here diff --git a/roc_fnb/util/__init__.py b/roc_fnb/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roc_fnb/util/base64.py b/roc_fnb/util/base64.py new file mode 100644 index 0000000..293dd72 --- /dev/null +++ b/roc_fnb/util/base64.py @@ -0,0 +1,30 @@ +from base64 import b64encode +from binascii import a2b_base64 +import binascii + + +def base64_decode(string: str|bytes) -> bytes: + """ + Python's base64.b64_decode requires padding to be correct. This does not. + + What silly bullshit. + """ + encoded: bytes = string.encode() if isinstance(string, str) else string + # padc = 4 - len(encoded) % 4 + # if padc == 4: + # padc = 0 + # fixed = encoded + b'=' * padc + fixed = encoded + b'====' + try: + return a2b_base64(fixed, strict_mode=False) + except binascii.Error as e: + print(f'{string=!r}\n{len(string)=!r}\n{padc=!r}\n{fixed=!r}\n{len(fixed)=!r}') + raise e + +def base64_encode(data: bytes) -> str: + """ + Return a base64 encoded string with no padding + + Python's b64encode returns bytes. Why? + """ + return b64encode(data).decode('utf-8').rstrip('=') \ No newline at end of file diff --git a/roc_fnb/util/test_base64.py b/roc_fnb/util/test_base64.py new file mode 100644 index 0000000..270886b --- /dev/null +++ b/roc_fnb/util/test_base64.py @@ -0,0 +1,11 @@ +from random import randbytes + +from roc_fnb.util.base64 import * + + +def test_encode_and_decode(): + for size in range(20-40): + data = randbytes(32) + string = base64_encode(data) + decoded = base64_decode(string) + assert data == decoded \ No newline at end of file diff --git a/roc_fnb/website/database.py b/roc_fnb/website/database.py new file mode 100644 index 0000000..fca003e --- /dev/null +++ b/roc_fnb/website/database.py @@ -0,0 +1,71 @@ +from os import environ +from typing import Optional, Self +from pdb import set_trace as debugger + +from bson.objectid import ObjectId +import pymongo +from pymongo import MongoClient + +from roc_fnb.website.models.user import User + +KEYLEN = 64 +with open('private-key.pem') as pk: + PRIVATE_KEY = pk.read() +with open('public-key.pem') as pk: + PUBLIK_KEY = pk.read() + + +class Database(MongoClient): + @classmethod + def from_env(cls) -> Self: + return cls( + host=environ.get('DATABASE_HOST', default='localhost'), + port=int(environ.get('DATABASE_PORT', default=27017)) + ) + + @property + def db(self) -> pymongo.database.Database: + if (env := environ.get('ENV_MODE')) and env == 'production': + return self.production + else: + return self.development + + def store_user(self, user: User) -> User: + """Store the given user in the database, set _id on it, and return it""" + result = self.db.users.insert_one(user.document) + user._id = result.inserted_id + return user + + def get_user_by_email(self, email: str) -> Optional[User]: + """ + Return the user associated with the given email. + + This does not imply authentiation + """ + if result := self.db.users.find_one({'email': email}): + return User(**result) + return None + + def get_user_by_name(self, name: str) -> Optional[User]: + if result := self.db.users.find_one({'name': name}): + return User(**result) + return None + + def delete_user(self, _id: ObjectId): + self.db.users.delete_one({'_id': _id}) + + def get_user_by_id(self, id: ObjectId) -> Optional[User]: + if user := self.db.users.find_one({'_id': id}): + return User(**user) + return None + + def get_user_from_token(self, token: str) -> Optional[User]: + """ + Verify a user and retreive a their full profile from the database. + + This is like User.verify_jwt except it also fetches fields from the + database which are not present in the client-visible token. + """ + if jwt_user := User.verify_jwt(token): + return self.get_user_by_id(jwt_user._id) + return None \ No newline at end of file diff --git a/roc_fnb/website/models/__init__.py b/roc_fnb/website/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roc_fnb/website/models/test_user.py b/roc_fnb/website/models/test_user.py new file mode 100644 index 0000000..bc16b03 --- /dev/null +++ b/roc_fnb/website/models/test_user.py @@ -0,0 +1,85 @@ +import json +from random import randbytes + +from bson.objectid import ObjectId +from pytest import fixture + +from roc_fnb.util.base64 import base64_decode +from roc_fnb.website.database import Database +from roc_fnb.website.models.user import User + + +@fixture +def user() -> User: + return User.create('test@t.co', 'name', 'monkey') + + +@fixture +def database() -> Database: + return Database.from_env() + + +def test_user_and_check_password(user): + assert user.name == 'name' + assert user.email == 'test@t.co' + assert user._id is None + assert user.check_password('monkey') + + +def test_jwt(user): + user._id = (_id := ObjectId(randbytes(12))) + token = user.jwt + header, payload, sig = (base64_decode(part.replace('.', '')) + for part in token.split('.')) + header = json.loads(header) + payload = json.loads(payload) + assert header['alg'] == 'RS256' + assert header['typ'] == 'JWT' + assert set(header.keys()) == {'alg', 'typ'} + # Note that JWT contents are visible to the user: this can be useful but + # must be done with caution + assert payload['email'] == user.email + assert payload['name'] == user.name + assert ObjectId(base64_decode(payload['_id'])) == user._id == _id + assert set(payload.keys()) == {'email', 'name', '_id', 'admin', 'moderator'} + + result = user.verify_jwt(token) + assert result.email == user.email + assert result.name == user.name + assert result._id == user._id == _id + assert not result.admin + assert not result.moderator + + +def test_store_and_retreive(user: User, database: Database): + try: + database.store_user(user) + assert user._id is not None + retreived = database.get_user_by_email(user.email) + assert retreived is not None + assert retreived._id == user._id + assert retreived == user + finally: + if id := user._id: + database.delete_user(id) + + +def test_store_and_retreive_by_id(user: User, database: Database): + try: + database.store_user(user) + assert user._id is not None + retreived = database.get_user_by_id(user._id) + assert retreived == user + finally: + if id := user._id: + database.delete_user(id) + +def test_store_and_retreive_by_jwt(user: User, database: Database): + try: + token = database.store_user(user).jwt + assert user._id is not None + retreived = database.get_user_from_token(token) + assert retreived == user + finally: + if id := user._id: + database.delete_user(id) \ No newline at end of file diff --git a/roc_fnb/website/models/user.py b/roc_fnb/website/models/user.py new file mode 100644 index 0000000..47f4479 --- /dev/null +++ b/roc_fnb/website/models/user.py @@ -0,0 +1,90 @@ +from base64 import b64decode, b64encode +from dataclasses import dataclass +import json +from random import randbytes +from typing import Optional, Any + +from bson.objectid import ObjectId +import scrypt +import jwt + +from roc_fnb.util.base64 import base64_encode, base64_decode + +with open('private-key.pem') as file: + PRIVATE_KEY = file.read() + +with open('public-key.pem') as file: + PUBLIC_KEY = file.read() + +@dataclass +class JwtUser: + _id: ObjectId + email: str + name: str + moderator: bool + admin: bool + +@dataclass +class User: + _id: Optional[ObjectId] + email: str + name: str + password_hash: bytes + salt: bytes + moderator: bool + admin: bool + + @classmethod + def create(cls, email: str, name: str, password: str|bytes, moderator: bool = False, admin: bool = False): + """Alternate constructor which hashes a given password""" + salt = randbytes(32) + password_hash = scrypt.hash(password, salt) + return cls(_id=None, + email=email, + name=name, + password_hash=password_hash, + salt=salt, + moderator=moderator, + admin=admin) + + @property + def document(self): + doc = { + "email": self.email, + "name": self.name, + "password_hash": self.password_hash, + "salt": self.salt, + "moderator": self.moderator, + "admin": self.admin, + } + if self._id is not None: + doc['_id'] = self._id + return doc + + @property + def public_fields(self): + return { + '_id': base64_encode(self._id.binary), + "email": self.email, + "name": self.name, + "moderator": self.moderator, + "admin": self.admin, + } + + def check_password(self, password: str) -> bool: + return self.password_hash == scrypt.hash(password, self.salt) + + @property + def jwt(self) -> str: + return jwt.encode(self.public_fields, PRIVATE_KEY, algorithm='RS256') + + @staticmethod + def verify_jwt(token: str) -> JwtUser: + verified = jwt.decode(token, PUBLIC_KEY, verify=True, algorithms=['RS256']) + return JwtUser( + _id=ObjectId(base64_decode(verified['_id'])), + name=verified['name'], + email=verified['email'], + moderator=verified['moderator'], + admin=verified['admin'], + ) diff --git a/rocfnb.org_donate_qr.png b/rocfnb.org_donate_qr.png deleted file mode 100644 index 11afd014f4f2976a580c42b6f340189a5c15089e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 316 zcmeAS@N?(olHy`uVBq!ia0vp^>p+;18Awh&v}7%iVhivIaRt)<|NmclbN*c*i>V~Y zFZloe6I<^e2l6-zJR*x37`TN%nDNrxx<5d{bDl1aAs(G?uQ~FyDDbdeP?Kp;E-dXk z!suc3S6A6;)3ftU#}?SdnrvCH==6lc?e&oYu3NJXhdweqx7K6b%Bh8?AF1&roSt&7 z;eSFe=i^Pzmr`}N7ny8|jQa9DtnWo+`UDBj$gG`Z_A`2}4!{W=W89Q z-pT9NhBvYN`{%az^Su*Rbqm%^JT5F0-zy`ZrKJDp!$bEPhVq_kM_uP?gTjfy)78&q Iol`;+0JvX<6#xJL diff --git a/rocfnb.org_donate_qr.svg b/rocfnb.org_donate_qr.svg deleted file mode 100644 index 12b7fe5..0000000 --- a/rocfnb.org_donate_qr.svg +++ /dev/null @@ -1,233 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -