====== Member Portal V2 ====== This is a project driven by @samp20 to build a new member portal to provide Single-Sign-On (SSO) to other Hackspace services. ===== Architecture ===== The system is based on a Python Flask application with PostgreSQL as the backend database. Below is a diagram showing the system dependencies: {{drawio>projects:member_portal:modules.png}} ===== Left to do ===== Required for initial demo: * Portal homepage * Hardcode OAuth permissions for demo * Logout * Host demo under Hackspace beta domain * Sample OpenID compatible application (suggestions welcome) Shortly after: * Rate limiter * By attempted email address * By IP address * Object cleanup system Future work: * Permissions system * PKCE * View/edit personal details * View/delete active sessions * External logins (keyfob) * Admin pages * Clients * Roles * Members ===== Project layout ===== * ''portal/'': The whole application. * ''forms/'': WTForms. * ''static/'': Static files, e.g. css. * ''systems/'': Core systems, e.g. Session, OAuth, JWK. * ''templates/'': Jinja templates. * ''views/'': Flask Blueprints with routes. Glue between templates and systems. * ''%%__init__%%.py'': Application entrypoint. * ''extensions.py'': Instantiate extensions and systems. * ''models.py'': SQLAlchemy models. * ''tests/'': Tests. Mainly for systems. ==== Systems ==== Systems will follow a similar style to Flask extensions. Flask requires that extensions don't store state directly on the class itself, but instead obtain it from the ''current_app''. This allows multiple applications to be instantiated, for example when unit testing. class _State: def __init__(self, app: Flask): self.greeting: str = app.config["MYSYSTEM_GREETING"] class MySystem: def __init__(self, other: OtherSystem, app: Flask|None): self.other = other if app is not None: self.init_app(app) def init_app(self, app: Flask): state = _State(app) app.extensions["hs.portal.my_system"] = state @property def _state(self) -> _State: state = current_app.extensions["hs.portal.my_system"] return state def get_greeting(self) -> str: return self._state.greeting ===== Testing ===== We will use pytest for our testing. We should aim to test each system in isolation. Some code will require an active request to test. We can create test endpoints to satisfy these instead of using the fully templated ones. For now we can probably use an in-memory SQLite database for testing. We may need to switch to a proper PostgreSQL database if we start to depend on DB specific features, at which point we'll need to clean the database before every test run. ===== Models ===== {{drawio>projects:member_portal:models.png}} ===== Tokens ===== A common pattern that's required is to store an external reference to a table row in a secure way. The pattern we use is to store an ''id'' of type UUID and a ''token_hash'' of type string in the table. The external token will refer to this using the string format ''{id.hex}:{token}''. The ID is used to select the correct row and the SHA256 of ''token'' is compared against ''token_hash''. The ''token'' is required as a layer of security as we cannot guarantee the ''id'' generation is cryptographically secure. It also allows us to rotate the ''token'' while still referring to the same row, for example cookie rotation.