diff --git a/.gitignore b/.gitignore index ebb8a8d..676ddc8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/* config.json result todo.md +*.db diff --git a/config.example.json b/config.example.json index 28f85f3..a994526 100644 --- a/config.example.json +++ b/config.example.json @@ -6,6 +6,10 @@ } "email": { "bird_api_key": "asdf1234", - "sender": "me@example.com" + "sender": "noreply@verifiedsenderdomain.com" + }, + "database": { + "url": "sqlite://", + "echo": false } } diff --git a/derivation.nix b/derivation.nix index 803e327..e5cc219 100644 --- a/derivation.nix +++ b/derivation.nix @@ -3,7 +3,7 @@ with python313Packages; buildPythonApplication { pname = "cereal"; version = "0.0.1"; - propagatedBuildInputs = [ flask requests waitress]; + propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi]; src = ./.; pyproject = true; build-system = [setuptools]; diff --git a/shell.nix b/shell.nix index 80b3c0d..2a39902 100644 --- a/shell.nix +++ b/shell.nix @@ -4,6 +4,8 @@ let requests flask waitress + sqlalchemy + argon2-cffi ]); in with pkgs; diff --git a/src/config/__init__.py b/src/config/__init__.py index c83bc3e..32b37b3 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -4,7 +4,8 @@ from typing import Any from .logging import Logging from .email import Email -from .parse import ConfigParseError, assert_key_of_type +from .database import Database +from .parse import assert_key_of_type, parse_nested_config @dataclass @@ -13,6 +14,7 @@ class Config: port: int | None logging: Logging email: Email + database: Database @classmethod def from_dict(cls, config: dict[str, Any]) -> 'Config': @@ -20,17 +22,13 @@ class Config: assert_key_of_type(config, 'email', dict) assert_key_of_type(config, 'host', str) assert_key_of_type(config, 'port', int) - try: - log_config = Logging.from_dict(config['logging']) - except ConfigParseError as e: - raise ConfigParseError(['logging', *e.keypath], e.issue) from e + log_config = parse_nested_config(config, 'logging', Logging.from_dict) + email_config = parse_nested_config(config, 'email', Email.from_dict) + db_config = parse_nested_config(config, 'database', Database.from_dict) - try: - email_config = Email.from_dict(config['email']) - except ConfigParseError as e: - raise ConfigParseError(['email', *e.keypath], e.issue) from e - - return Config(host=config['host'], port=config['port'], email=email_config, logging=log_config) + return Config( + host=config['host'], port=config['port'], email=email_config, logging=log_config, database=db_config + ) def parse_config(filename: str) -> Config: diff --git a/src/config/database.py b/src/config/database.py new file mode 100644 index 0000000..625473d --- /dev/null +++ b/src/config/database.py @@ -0,0 +1,16 @@ +from src.config.parse import assert_key_of_type +from typing import Any +from dataclasses import dataclass + + +@dataclass +class Database: + url: str + echo: bool + + @classmethod + def from_dict(cls, config: dict[str, Any]) -> 'Database': + assert_key_of_type(config, 'url', str) + assert_key_of_type(config, 'echo', bool) + + return Database(url=config['url'], echo=config['echo']) diff --git a/src/config/email.py b/src/config/email.py index e1d2409..5993919 100644 --- a/src/config/email.py +++ b/src/config/email.py @@ -1,3 +1,4 @@ +from src.utils.secret import SecretBox from src.config.parse import assert_key_of_type from typing import Any from dataclasses import dataclass @@ -8,11 +9,11 @@ EmailAddress = str @dataclass class Email: - bird_api_key: str + bird_api_key: SecretBox[str] sender: EmailAddress @classmethod def from_dict(cls, config: dict[str, Any]) -> 'Email': assert_key_of_type(config, 'bird_api_key', str) assert_key_of_type(config, 'sender', str) - return Email(bird_api_key=config['bird_api_key'], sender=config['sender']) + return Email(bird_api_key=SecretBox(config['bird_api_key']), sender=config['sender']) diff --git a/src/config/parse.py b/src/config/parse.py index c940175..c50df3f 100644 --- a/src/config/parse.py +++ b/src/config/parse.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, TypeVar, Callable class ConfigParseError(Exception): @@ -18,3 +18,14 @@ def assert_key_of_type(config: dict[str, Any], key: str, kind: Any): if not isinstance(config[key], kind): raise ConfigParseError([key], f'type of "{key}" was {type(config[key])}, expected {kind}') + + +T = TypeVar('T') + + +def parse_nested_config(config: dict[str, Any], key: str, parse: Callable[[dict[str, Any]], T]) -> T: + assert_key_of_type(config, key, dict) + try: + return parse(config[key]) + except ConfigParseError as e: + raise ConfigParseError([key, *e.keypath], e.issue) diff --git a/src/infra/__init__.py b/src/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infra/db.py b/src/infra/db.py new file mode 100644 index 0000000..841cbb8 --- /dev/null +++ b/src/infra/db.py @@ -0,0 +1,15 @@ +from sqlalchemy.orm import DeclarativeBase +from src.config.database import Database as DatabaseConfig +from sqlalchemy import create_engine + + +class Base(DeclarativeBase): + pass + + +def get_database(config: DatabaseConfig): + engine = create_engine(config.url, echo=config.echo) + # Ensure that all tables exist in the database + Base.metadata.create_all(engine) + + return engine diff --git a/src/infra/users.py b/src/infra/users.py new file mode 100644 index 0000000..8a3c24d --- /dev/null +++ b/src/infra/users.py @@ -0,0 +1,84 @@ +import logging +from argon2.exceptions import VerifyMismatchError +from src.utils.secret import SecretBox +from src.config.email import EmailAddress +from sqlalchemy import String, Boolean, VARCHAR, Engine, select +from sqlalchemy.orm import mapped_column, Mapped, Session +from argon2 import PasswordHasher +from src.infra.db import Base +from src.services.users import UserRepo, UserDTO, User + + +class UserModel(Base): + __tablename__ = 'user' + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(254), unique=True) + email_confirmed: Mapped[bool] = mapped_column(Boolean()) + password_hash: Mapped[str] = mapped_column(VARCHAR(255)) + + +def model_to_user(db_user: UserModel) -> User: + return User( + id=db_user.id, + email=db_user.email, + email_confirmed=db_user.email_confirmed, + password_hash=SecretBox(db_user.password_hash), + ) + + +class UserRepoImpl(UserRepo): + def __init__(self, db: Engine): + self._db = db + self._hasher = PasswordHasher() + self._logger = logging.getLogger('UserRepoImpl') + + def create_user(self, user: UserDTO) -> User: + pw = self._hasher.hash(user.raw_password.expose_secret()) + db_user = UserModel(email=user.email, password_hash=pw) + with Session(self._db) as session: + session.add(db_user) + session.commit() + + return model_to_user(db_user) + + def get_user_by_id(self, user_id: int) -> User | None: + with Session(self._db) as session: + user = session.get(UserModel, user_id) + + if user: + return model_to_user(user) + + def get_user_by_email(self, email: EmailAddress) -> User | None: + with Session(self._db) as session: + user = session.scalar(select(UserModel).where(UserModel.email == email)) + + if user: + return model_to_user(user) + + def update_user(self, user: User): + with Session(self._db) as session: + db_user = session.get(UserModel, user.id) + if db_user is None: + self._logger.warning(f'Attempted to update non-existing user {user.id}') + return + + # These fields can be set directly + db_user.email_confirmed = user.email_confirmed + db_user.password_hash = user.password_hash.expose_secret() + + # If the email is being updated then it should not be considered confirmed. + if db_user.email != user.email: + db_user.email = user.email + db_user.email_confirmed = False + + session.commit() + + def auth_as_user(self, user: UserDTO) -> User | None: + full_user = self.get_user_by_email(user.email) + if full_user is None: + return None + + try: + self._hasher.verify(full_user.password_hash.expose_secret(), user.raw_password.expose_secret()) + except VerifyMismatchError: + return None diff --git a/src/main.py b/src/main.py index 4e9c771..d046769 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,10 @@ #!/usr/bin/env python +from src.notifications.service import make_notification_service +from src.services.email import get_service as get_email_service +from src.services.users import get_service as get_user_service +from src.infra.db import get_database +from src.infra.users import UserRepoImpl from waitress import serve from flask import Flask import argparse @@ -15,6 +20,16 @@ def create_app(name: str, config: Config) -> Flask: app = Flask(name) + email_service = get_email_service(config.email) + notification_service = make_notification_service(email_service) + database = get_database(config.database) + user_repo = UserRepoImpl(database) + user_service = get_user_service(user_repo) + + app.extensions['email'] = email_service + app.extensions['notifications'] = notification_service + app.extensions['user_service'] = user_service + if config.host: app.config['SERVER_NAME'] = config.host diff --git a/src/notifications/service.py b/src/notifications/service.py index ecd6717..ed64cce 100644 --- a/src/notifications/service.py +++ b/src/notifications/service.py @@ -1,12 +1,55 @@ -from typing import Any -from .data import Notification import abc +from typing import Any + +from src.services.email import EmailService +from src.config import Config + +from .data import Notification + +# MUSTFIX: Needs real users! -class NotificationService(abc.ABC): +class NotificationSender(abc.ABC): """A service that sends notifications to the user""" + def __init__(self, name: str): + self._name = name + + @property + def name(self): + return self._name + @abc.abstractmethod def notify(self, user: Any, notification: Notification): """Send a notification""" pass + + +class NotificationService: + def __init__(self): + self._senders = [] + + def register_sender(self, sender: NotificationSender): + self._senders.append(sender) + + def notify_user(self, user: Any, notification: Notification): + for s in self._senders: + if s.name in user.notification_methods: + s.notify(user, notification) + + +class EmailNotifier(NotificationSender): + def __init__(self, email_service: EmailService): + super().__init__('email') + self._email_service = email_service + + def notify(self, user: Any, notification: Notification): + # TODO: Convert notification and user information into an EmailDTO for the email service + pass + + +def make_notification_service(email_service: EmailService): + service = NotificationService() + service.register_sender(EmailNotifier(email_service)) + + return service diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/email.py b/src/services/email.py index d6c4f5c..dc51ad0 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass, field -from email.utils import formatdate, make_msgid +import requests +from dataclasses import dataclass import abc from ..config.email import Email as EmailConfig, EmailAddress @@ -17,10 +17,6 @@ class EmailDTO: text: str # HTML alternate content. (Optional: Textual content is required) html: str | None = None - # A unique ID for identifying this Email - message_id: str = field(default_factory=make_msgid) - # The date that the email was sent - date: str = field(default_factory=lambda: formatdate(localtime=True)) class EmailService: @@ -30,12 +26,20 @@ class EmailService: class BirdEmailServiceImpl(EmailService): + BIRD_API_ENDPOINT = 'https://eu1.platform.bird.com/v1/email/messages' + def __init__(self, config: EmailConfig): self._config = config def send_email(self, email: EmailDTO): - # TODO: Implement Bird Specific Email sending and Error handling - pass + payload = {'from': email.sender, 'to': email.to, 'subject': email.subject, 'text': email.text} + + if email.html: + payload['html'] = email.html + + headers = {'Authorization': f'Bearer {self._config.bird_api_key.expose_secret()}'} + response = requests.post(BirdEmailServiceImpl.BIRD_API_ENDPOINT, json=payload, headers=headers) + return response.ok def get_service(config: EmailConfig) -> EmailService: diff --git a/src/services/users.py b/src/services/users.py new file mode 100644 index 0000000..9b4a0c2 --- /dev/null +++ b/src/services/users.py @@ -0,0 +1,102 @@ +from email.headerregistry import Address +import abc +from dataclasses import dataclass +from src.utils.secret import SecretBox +from src.config.email import EmailAddress + + +@dataclass +class UserProfile: + id: int + email: EmailAddress + + +@dataclass +class User: + id: int + email: EmailAddress + email_confirmed: bool + password_hash: SecretBox[str] + + def to_profile(self) -> UserProfile: + return UserProfile(id=self.id, email=self.email) + + +@dataclass +class UserDTO: + email: EmailAddress + raw_password: SecretBox[str] + + +class LoginError(Exception): + pass + + +class SignupError(Exception): + pass + + +class UserRepo(abc.ABC): + @abc.abstractmethod + def create_user(self, user: UserDTO) -> User: + pass + + @abc.abstractmethod + def get_user_by_id(self, user_id: int) -> User | None: + pass + + @abc.abstractmethod + def get_user_by_email(self, email: EmailAddress) -> User | None: + pass + + @abc.abstractmethod + def update_user(self, user: User): + pass + + @abc.abstractmethod + def auth_as_user(self, user: UserDTO) -> User | None: + pass + + +def is_valid_email(email: str) -> bool: + try: + parsed = Address(addr_spec=email) + return bool(parsed.username and parsed.domain and '.' in parsed.domain) + except (ValueError, TypeError): + return False + + +def is_valid_password(password: str) -> bool: + # TODO: Enforce other or saner rules? + long_enough = len(password) > 8 + short_enough = len(password) < 32 + has_symbol = any([s in password for s in list('@#$%^&*!?/')]) + return long_enough and short_enough and has_symbol + + +class UserService: + def __init__(self, repo: UserRepo): + self._repo = repo + + def login(self, user: UserDTO) -> UserProfile | None: + full_user = self._repo.auth_as_user(user) + if full_user: + return full_user.to_profile() + + def signup(self, user: UserDTO) -> UserProfile: + if not is_valid_email(user.email): + raise SignupError(f'{user.email} was not an acceptable email address') + + if not is_valid_password(user.raw_password.expose_secret()): + raise SignupError('The given password was not acceptable') + + return self._repo.create_user(user).to_profile() + + def get_user_by_id(self, user_id: int) -> UserProfile | None: + user = self._repo.get_user_by_id(user_id) + if user: + return user.to_profile() + + +def get_service(repo: UserRepo) -> UserService: + return UserService(repo) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/secret.py b/src/utils/secret.py new file mode 100644 index 0000000..1828827 --- /dev/null +++ b/src/utils/secret.py @@ -0,0 +1,14 @@ +class SecretBox[T]: + """A helper abstraction for making sure secrets aren't leaked accidentally""" + + def __init__(self, item: T): + self._item = item + + def expose_secret(self) -> T: + return self._item + + def __str__(self): + return '' + + def __repr__(self): + return f'SecretBox<{type(self._item)}>'