From 15770f28798d9ead9e89dfab89f47ea40b91d45e Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Wed, 28 May 2025 10:16:13 -0400 Subject: [PATCH 1/7] 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 07fe8f6ffcd8de633f8040861b856bd1c4af3184 Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Fri, 30 May 2025 09:12:39 -0400 Subject: [PATCH 2/7] login flow through ui --- .gitignore | 3 +- pyproject.toml | 1 + roc_fnb/scripts/__init__.py | 0 roc_fnb/scripts/bootstrap_first_admin.py | 24 ++++++++++ roc_fnb/website/server.py | 44 ++++++++++++++++- roc_fnb/website/templates/base.html | 27 +++++++++++ roc_fnb/website/templates/login.html | 60 ++++++++++++++++++++++++ roc_fnb/website/templates/profile.html | 7 +++ 8 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 roc_fnb/scripts/__init__.py create mode 100644 roc_fnb/scripts/bootstrap_first_admin.py create mode 100644 roc_fnb/website/templates/base.html create mode 100644 roc_fnb/website/templates/login.html create mode 100644 roc_fnb/website/templates/profile.html diff --git a/.gitignore b/.gitignore index 0a93a67..a8e852f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ build/ **/*.egg-info **/__pycache__ mounts/ -**/*.pem \ No newline at end of file +**/*.pem +**/*.secret diff --git a/pyproject.toml b/pyproject.toml index dbad4db..b5d3445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,4 @@ packages = ["roc_fnb"] [project.scripts] # Put scripts here +bootstrap-first-admin = "roc_fnb.scripts.bootstrap_first_admin:bootstrap_first_admin" diff --git a/roc_fnb/scripts/__init__.py b/roc_fnb/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roc_fnb/scripts/bootstrap_first_admin.py b/roc_fnb/scripts/bootstrap_first_admin.py new file mode 100644 index 0000000..8aef1ea --- /dev/null +++ b/roc_fnb/scripts/bootstrap_first_admin.py @@ -0,0 +1,24 @@ +from click import command, option, prompt, confirm +from roc_fnb.website.database import Database +from roc_fnb.website.models.user import User + + +@command +@option('--name', '-n', type=str, required=True) +@option('--email', '-e', type=str, required=True) +def bootstrap_first_admin(name: str, email: str): + password = prompt('Enter the account password', + hide_input=True, prompt_suffix=': ') + confirmation = prompt('Confirm the account password', + hide_input=True, prompt_suffix=': ') + if password != confirmation: + raise ValueError('passwords did not match') + admin = User.create(email, name, password, moderator=True, admin=True) + db = Database.from_env() + db.store_user(admin) + if confirm('Display an auth token for testing?', default=False): + print(admin.jwt) + + +if __name__ == '__main__': + bootstrap_first_admin() diff --git a/roc_fnb/website/server.py b/roc_fnb/website/server.py index 083e0fc..e6facd1 100644 --- a/roc_fnb/website/server.py +++ b/roc_fnb/website/server.py @@ -1,19 +1,59 @@ -from flask import Flask, redirect, url_for, request, send_file +from pathlib import Path +from sys import stderr + +from flask import (Flask, redirect, url_for, request, send_file, make_response, + abort, render_template, g) + +from roc_fnb.website.database import Database +from roc_fnb.website.models.user import User + +db = Database.from_env() app = Flask( import_name=__name__.split('.')[0], - static_url_path='/' + static_url_path='/', + template_folder=Path(__file__).absolute().parent / 'templates', + static_folder=Path(__file__).absolute().parent / 'static', ) + +@app.before_request +def decode_user(): + if token := request.cookies.get('auth-token'): + g.user = User.verify_jwt(token) + + @app.route('/ig') def ig_redir(): return redirect('https://instagram.com/RocFNB') + @app.route('/donate') def donate_redir(): return redirect('https://venmo.com/RocFoodNotBombs') + @app.route('/') def index(): return redirect('/index.html') + +@app.post('/login') +def submit_login(): + form = request.json + user = db.get_user_by_name(form['name']) + if not user.check_password(form['password']): + abort(401) # unauthorized + response = make_response(redirect('/me')) + response.set_cookie('auth-token', user.jwt) + return response + +@app.get('/login') +def render_login_page(): + return render_template('login.html') + +@app.get('/me') +def get_profile(): + if g.user is not None: + return render_template('profile.html', user=g.user) + abort(401) diff --git a/roc_fnb/website/templates/base.html b/roc_fnb/website/templates/base.html new file mode 100644 index 0000000..682d919 --- /dev/null +++ b/roc_fnb/website/templates/base.html @@ -0,0 +1,27 @@ + + + + + + Rochester Food Not Bombs + + + + + + + + + {% block content %} + {% endblock %} + + + \ No newline at end of file diff --git a/roc_fnb/website/templates/login.html b/roc_fnb/website/templates/login.html new file mode 100644 index 0000000..3d32889 --- /dev/null +++ b/roc_fnb/website/templates/login.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} {% block content %} + + + + +

Rochester Food Not Bombs!

+ +

Login:

+ +
+ + +
+
+ + +
+
+ +
+ +{% endblock %} diff --git a/roc_fnb/website/templates/profile.html b/roc_fnb/website/templates/profile.html new file mode 100644 index 0000000..5f1ae5a --- /dev/null +++ b/roc_fnb/website/templates/profile.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block content %} + +

Rochester Food Not Bombs!

+ +

This will be the profile/settings page for {{user.name}}

+{% endblock %} \ No newline at end of file From 9d1de005d03ee462db1066f0c9ca296cd239765f Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Sat, 31 May 2025 07:22:31 -0400 Subject: [PATCH 3/7] Use flask session store instead of DIYing --- roc_fnb/util/env_file.py | 29 ++++++++++++++++++++ roc_fnb/website/models/test_user.py | 35 ------------------------ roc_fnb/website/models/user.py | 28 +++++++++---------- roc_fnb/website/server.py | 42 ++++++++++++++++++++++------- 4 files changed, 73 insertions(+), 61 deletions(-) create mode 100644 roc_fnb/util/env_file.py diff --git a/roc_fnb/util/env_file.py b/roc_fnb/util/env_file.py new file mode 100644 index 0000000..c07baae --- /dev/null +++ b/roc_fnb/util/env_file.py @@ -0,0 +1,29 @@ +from os import environ + + +def env_file(key, default_file=KeyError, default=KeyError, default_fn=KeyError): + """ + Return a value from an environment variable or file specified by one. + + Checks first for the value specified by key with "_FILE" appended. If that + is found, read from the file there. Otherwise return the value of the + environment variable, the contents of the specified default file, the default + value, or raises KeyError. + """ + if fp := environ.get(f'{key}_FILE'): + with open(fp) as file: + return file.read() + if var := environ.get(key): + return var + if default_file is not KeyError: + try: + with open(default_file) as file: + return file.read() + except FileNotFoundError: + ... # fallthrough + if default is not KeyError: + return default + if default_fn is not KeyError: + return default_fn() + raise KeyError(f'no environment variable found ${key} nor {key}_FILE and default was not specified') + \ No newline at end of file diff --git a/roc_fnb/website/models/test_user.py b/roc_fnb/website/models/test_user.py index bc16b03..74a4b37 100644 --- a/roc_fnb/website/models/test_user.py +++ b/roc_fnb/website/models/test_user.py @@ -26,31 +26,6 @@ def test_user_and_check_password(user): 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) @@ -73,13 +48,3 @@ def test_store_and_retreive_by_id(user: User, database: Database): 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 index 47f4479..6c52ee7 100644 --- a/roc_fnb/website/models/user.py +++ b/roc_fnb/website/models/user.py @@ -2,7 +2,7 @@ from base64 import b64decode, b64encode from dataclasses import dataclass import json from random import randbytes -from typing import Optional, Any +from typing import Optional, Any, Self from bson.objectid import ObjectId import scrypt @@ -24,6 +24,12 @@ class JwtUser: moderator: bool admin: bool + @classmethod + def from_json(cls, data: dict) -> Self: + _id = ObjectId(base64_decode(data.pop('_id'))) + return cls(_id=_id, **data) + + @dataclass class User: _id: Optional[ObjectId] @@ -63,6 +69,11 @@ class User: @property def public_fields(self): + """ + Session data is visible to client scripts. + + This is a feature, not a bug; client scripts may need to gather login info. + """ return { '_id': base64_encode(self._id.binary), "email": self.email, @@ -73,18 +84,3 @@ class User: 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/roc_fnb/website/server.py b/roc_fnb/website/server.py index e6facd1..714f96d 100644 --- a/roc_fnb/website/server.py +++ b/roc_fnb/website/server.py @@ -1,11 +1,15 @@ +from functools import wraps +import json from pathlib import Path +from random import randbytes from sys import stderr from flask import (Flask, redirect, url_for, request, send_file, make_response, - abort, render_template, g) + abort, render_template, session, g) +from roc_fnb.util.env_file import env_file from roc_fnb.website.database import Database -from roc_fnb.website.models.user import User +from roc_fnb.website.models.user import JwtUser db = Database.from_env() @@ -16,13 +20,31 @@ app = Flask( static_folder=Path(__file__).absolute().parent / 'static', ) +app.secret_key = env_file('FLASK_SECRET', default_file='./flask.secret', default_fn=lambda: randbytes(12)) @app.before_request def decode_user(): - if token := request.cookies.get('auth-token'): - g.user = User.verify_jwt(token) + if user := session.get('user'): + g.user = JwtUser.from_json(data=json.loads(user)) +def require_user(admin = False, moderator = False): + """ + A decorator for any routes which require authentication. + + https://stackoverflow.com/a/51820573 + """ + def _require_user(handler): + @wraps(handler) + def __require_user(): + if getattr(g, 'user', None) is None \ + or (admin and not user.admin) \ + or (moderator and not user.moderator): + abort(401) + return handler() + return __require_user + return _require_user + @app.route('/ig') def ig_redir(): return redirect('https://instagram.com/RocFNB') @@ -44,16 +66,16 @@ def submit_login(): user = db.get_user_by_name(form['name']) if not user.check_password(form['password']): abort(401) # unauthorized - response = make_response(redirect('/me')) - response.set_cookie('auth-token', user.jwt) - return response + session['user'] = json.dumps(user.public_fields) + return redirect('/me') @app.get('/login') def render_login_page(): + if getattr(g, 'user', None): + return redirect('/me') return render_template('login.html') @app.get('/me') +@require_user() def get_profile(): - if g.user is not None: - return render_template('profile.html', user=g.user) - abort(401) + return render_template('profile.html', user=g.user) From ae5893c22fbe9fa2c35c5073eb285d0552c79098 Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Sat, 31 May 2025 09:10:17 -0400 Subject: [PATCH 4/7] add structured logging --- pyproject.toml | 9 ++++++++- roc_fnb/util/__init__.py | 1 + roc_fnb/util/logging.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 roc_fnb/util/__init__.py create mode 100644 roc_fnb/util/logging.py diff --git a/pyproject.toml b/pyproject.toml index 693bd80..c968c61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,15 @@ 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", + "structlog", +] dynamic = ["version"] [project.scripts] # Put scripts here + +[tool.yapf] +based_on_style = "facebook" \ No newline at end of file diff --git a/roc_fnb/util/__init__.py b/roc_fnb/util/__init__.py new file mode 100644 index 0000000..0be7677 --- /dev/null +++ b/roc_fnb/util/__init__.py @@ -0,0 +1 @@ +from roc_fnb.util.logging import log \ No newline at end of file diff --git a/roc_fnb/util/logging.py b/roc_fnb/util/logging.py new file mode 100644 index 0000000..b98508a --- /dev/null +++ b/roc_fnb/util/logging.py @@ -0,0 +1,30 @@ +import logging +from os import environ + +from structlog.processors import JSONRenderer, TimeStamper +from structlog.dev import ConsoleRenderer +import structlog + +if not structlog.is_configured(): + if (env := environ.get('ENV_MODE')) and env == 'production': + timestamper = TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=True) + renderer: JSONRenderer | ConsoleRenderer = JSONRenderer() + else: + timestamper = TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False) + renderer = ConsoleRenderer() + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.dev.set_exc_info, + timestamper, + renderer, + ], + wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=False + ) + +log = structlog.get_logger() From a481ae95267360e1299ed3373039fb70b7bd6b5f Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Wed, 28 May 2025 10:16:13 -0400 Subject: [PATCH 5/7] add user database code --- .gitignore | 2 + README.md | 11 ++ docker-compose.yml | 4 + pyproject.toml | 11 ++ 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 ---------------------------- 12 files changed, 315 insertions(+), 233 deletions(-) create mode 100644 README.md 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 c968c61..080e34f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,20 @@ dependencies = [ "flask", "gunicorn", "structlog", + "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/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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 41f36b0fd7008a1e493cddc5d09bcdce5c1e6700 Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Fri, 30 May 2025 09:12:39 -0400 Subject: [PATCH 6/7] login flow through ui --- .gitignore | 3 +- pyproject.toml | 3 +- roc_fnb/scripts/__init__.py | 0 roc_fnb/scripts/bootstrap_first_admin.py | 24 ++++++++++ roc_fnb/website/server.py | 44 ++++++++++++++++- roc_fnb/website/templates/base.html | 27 +++++++++++ roc_fnb/website/templates/login.html | 60 ++++++++++++++++++++++++ roc_fnb/website/templates/profile.html | 7 +++ 8 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 roc_fnb/scripts/__init__.py create mode 100644 roc_fnb/scripts/bootstrap_first_admin.py create mode 100644 roc_fnb/website/templates/base.html create mode 100644 roc_fnb/website/templates/login.html create mode 100644 roc_fnb/website/templates/profile.html diff --git a/.gitignore b/.gitignore index 0a93a67..a8e852f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ build/ **/*.egg-info **/__pycache__ mounts/ -**/*.pem \ No newline at end of file +**/*.pem +**/*.secret diff --git a/pyproject.toml b/pyproject.toml index 080e34f..46f89b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ packages = ["roc_fnb"] [project.scripts] # Put scripts here +bootstrap-first-admin = "roc_fnb.scripts.bootstrap_first_admin:bootstrap_first_admin" [tool.yapf] -based_on_style = "facebook" \ No newline at end of file +based_on_style = "facebook" diff --git a/roc_fnb/scripts/__init__.py b/roc_fnb/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roc_fnb/scripts/bootstrap_first_admin.py b/roc_fnb/scripts/bootstrap_first_admin.py new file mode 100644 index 0000000..8aef1ea --- /dev/null +++ b/roc_fnb/scripts/bootstrap_first_admin.py @@ -0,0 +1,24 @@ +from click import command, option, prompt, confirm +from roc_fnb.website.database import Database +from roc_fnb.website.models.user import User + + +@command +@option('--name', '-n', type=str, required=True) +@option('--email', '-e', type=str, required=True) +def bootstrap_first_admin(name: str, email: str): + password = prompt('Enter the account password', + hide_input=True, prompt_suffix=': ') + confirmation = prompt('Confirm the account password', + hide_input=True, prompt_suffix=': ') + if password != confirmation: + raise ValueError('passwords did not match') + admin = User.create(email, name, password, moderator=True, admin=True) + db = Database.from_env() + db.store_user(admin) + if confirm('Display an auth token for testing?', default=False): + print(admin.jwt) + + +if __name__ == '__main__': + bootstrap_first_admin() diff --git a/roc_fnb/website/server.py b/roc_fnb/website/server.py index 083e0fc..e6facd1 100644 --- a/roc_fnb/website/server.py +++ b/roc_fnb/website/server.py @@ -1,19 +1,59 @@ -from flask import Flask, redirect, url_for, request, send_file +from pathlib import Path +from sys import stderr + +from flask import (Flask, redirect, url_for, request, send_file, make_response, + abort, render_template, g) + +from roc_fnb.website.database import Database +from roc_fnb.website.models.user import User + +db = Database.from_env() app = Flask( import_name=__name__.split('.')[0], - static_url_path='/' + static_url_path='/', + template_folder=Path(__file__).absolute().parent / 'templates', + static_folder=Path(__file__).absolute().parent / 'static', ) + +@app.before_request +def decode_user(): + if token := request.cookies.get('auth-token'): + g.user = User.verify_jwt(token) + + @app.route('/ig') def ig_redir(): return redirect('https://instagram.com/RocFNB') + @app.route('/donate') def donate_redir(): return redirect('https://venmo.com/RocFoodNotBombs') + @app.route('/') def index(): return redirect('/index.html') + +@app.post('/login') +def submit_login(): + form = request.json + user = db.get_user_by_name(form['name']) + if not user.check_password(form['password']): + abort(401) # unauthorized + response = make_response(redirect('/me')) + response.set_cookie('auth-token', user.jwt) + return response + +@app.get('/login') +def render_login_page(): + return render_template('login.html') + +@app.get('/me') +def get_profile(): + if g.user is not None: + return render_template('profile.html', user=g.user) + abort(401) diff --git a/roc_fnb/website/templates/base.html b/roc_fnb/website/templates/base.html new file mode 100644 index 0000000..682d919 --- /dev/null +++ b/roc_fnb/website/templates/base.html @@ -0,0 +1,27 @@ + + + + + + Rochester Food Not Bombs + + + + + + + + + {% block content %} + {% endblock %} + + + \ No newline at end of file diff --git a/roc_fnb/website/templates/login.html b/roc_fnb/website/templates/login.html new file mode 100644 index 0000000..3d32889 --- /dev/null +++ b/roc_fnb/website/templates/login.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} {% block content %} + + + + +

Rochester Food Not Bombs!

+ +

Login:

+ +
+ + +
+
+ + +
+
+ +
+ +{% endblock %} diff --git a/roc_fnb/website/templates/profile.html b/roc_fnb/website/templates/profile.html new file mode 100644 index 0000000..5f1ae5a --- /dev/null +++ b/roc_fnb/website/templates/profile.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block content %} + +

Rochester Food Not Bombs!

+ +

This will be the profile/settings page for {{user.name}}

+{% endblock %} \ No newline at end of file From 467f6a77ae37ba72599a51efb06432c5bc9e803c Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Sat, 31 May 2025 07:22:31 -0400 Subject: [PATCH 7/7] Use flask session store instead of DIYing --- roc_fnb/util/env_file.py | 29 ++++++++++++++++++++ roc_fnb/website/models/test_user.py | 35 ------------------------ roc_fnb/website/models/user.py | 28 +++++++++---------- roc_fnb/website/server.py | 42 ++++++++++++++++++++++------- 4 files changed, 73 insertions(+), 61 deletions(-) create mode 100644 roc_fnb/util/env_file.py diff --git a/roc_fnb/util/env_file.py b/roc_fnb/util/env_file.py new file mode 100644 index 0000000..c07baae --- /dev/null +++ b/roc_fnb/util/env_file.py @@ -0,0 +1,29 @@ +from os import environ + + +def env_file(key, default_file=KeyError, default=KeyError, default_fn=KeyError): + """ + Return a value from an environment variable or file specified by one. + + Checks first for the value specified by key with "_FILE" appended. If that + is found, read from the file there. Otherwise return the value of the + environment variable, the contents of the specified default file, the default + value, or raises KeyError. + """ + if fp := environ.get(f'{key}_FILE'): + with open(fp) as file: + return file.read() + if var := environ.get(key): + return var + if default_file is not KeyError: + try: + with open(default_file) as file: + return file.read() + except FileNotFoundError: + ... # fallthrough + if default is not KeyError: + return default + if default_fn is not KeyError: + return default_fn() + raise KeyError(f'no environment variable found ${key} nor {key}_FILE and default was not specified') + \ No newline at end of file diff --git a/roc_fnb/website/models/test_user.py b/roc_fnb/website/models/test_user.py index bc16b03..74a4b37 100644 --- a/roc_fnb/website/models/test_user.py +++ b/roc_fnb/website/models/test_user.py @@ -26,31 +26,6 @@ def test_user_and_check_password(user): 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) @@ -73,13 +48,3 @@ def test_store_and_retreive_by_id(user: User, database: Database): 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 index 47f4479..6c52ee7 100644 --- a/roc_fnb/website/models/user.py +++ b/roc_fnb/website/models/user.py @@ -2,7 +2,7 @@ from base64 import b64decode, b64encode from dataclasses import dataclass import json from random import randbytes -from typing import Optional, Any +from typing import Optional, Any, Self from bson.objectid import ObjectId import scrypt @@ -24,6 +24,12 @@ class JwtUser: moderator: bool admin: bool + @classmethod + def from_json(cls, data: dict) -> Self: + _id = ObjectId(base64_decode(data.pop('_id'))) + return cls(_id=_id, **data) + + @dataclass class User: _id: Optional[ObjectId] @@ -63,6 +69,11 @@ class User: @property def public_fields(self): + """ + Session data is visible to client scripts. + + This is a feature, not a bug; client scripts may need to gather login info. + """ return { '_id': base64_encode(self._id.binary), "email": self.email, @@ -73,18 +84,3 @@ class User: 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/roc_fnb/website/server.py b/roc_fnb/website/server.py index e6facd1..714f96d 100644 --- a/roc_fnb/website/server.py +++ b/roc_fnb/website/server.py @@ -1,11 +1,15 @@ +from functools import wraps +import json from pathlib import Path +from random import randbytes from sys import stderr from flask import (Flask, redirect, url_for, request, send_file, make_response, - abort, render_template, g) + abort, render_template, session, g) +from roc_fnb.util.env_file import env_file from roc_fnb.website.database import Database -from roc_fnb.website.models.user import User +from roc_fnb.website.models.user import JwtUser db = Database.from_env() @@ -16,13 +20,31 @@ app = Flask( static_folder=Path(__file__).absolute().parent / 'static', ) +app.secret_key = env_file('FLASK_SECRET', default_file='./flask.secret', default_fn=lambda: randbytes(12)) @app.before_request def decode_user(): - if token := request.cookies.get('auth-token'): - g.user = User.verify_jwt(token) + if user := session.get('user'): + g.user = JwtUser.from_json(data=json.loads(user)) +def require_user(admin = False, moderator = False): + """ + A decorator for any routes which require authentication. + + https://stackoverflow.com/a/51820573 + """ + def _require_user(handler): + @wraps(handler) + def __require_user(): + if getattr(g, 'user', None) is None \ + or (admin and not user.admin) \ + or (moderator and not user.moderator): + abort(401) + return handler() + return __require_user + return _require_user + @app.route('/ig') def ig_redir(): return redirect('https://instagram.com/RocFNB') @@ -44,16 +66,16 @@ def submit_login(): user = db.get_user_by_name(form['name']) if not user.check_password(form['password']): abort(401) # unauthorized - response = make_response(redirect('/me')) - response.set_cookie('auth-token', user.jwt) - return response + session['user'] = json.dumps(user.public_fields) + return redirect('/me') @app.get('/login') def render_login_page(): + if getattr(g, 'user', None): + return redirect('/me') return render_template('login.html') @app.get('/me') +@require_user() def get_profile(): - if g.user is not None: - return render_template('profile.html', user=g.user) - abort(401) + return render_template('profile.html', user=g.user)