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/infra/__init__.py b/src/infra/__init__.py new file mode 100644 index 0000000..e69de29 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/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 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)