diff --git a/derivation.nix b/derivation.nix index 241000d..639f015 100644 --- a/derivation.nix +++ b/derivation.nix @@ -3,7 +3,7 @@ with python313Packages; buildPythonApplication { pname = "cereal"; version = "0.0.1"; - propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography jinja2]; + propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography]; src = ./.; pyproject = true; build-system = [setuptools]; diff --git a/setup.py b/setup.py index d244a90..af47279 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,5 @@ setup( verison='0.0.1', packages=find_packages(), include_package_data=True, - package_data={'src': ['templates/**/*.html', 'templates/**/*.txt']}, scripts=['./src/main.py'], ) diff --git a/shell.nix b/shell.nix index 585c993..50007eb 100644 --- a/shell.nix +++ b/shell.nix @@ -8,7 +8,6 @@ let argon2-cffi pyjwt cryptography - jinja2 ]); in with pkgs; @@ -21,5 +20,6 @@ mkShell { python313Packages.python-lsp-server python313Packages.jedi-language-server ty + ffmpeg ]; } diff --git a/src/main.py b/src/main.py index 93f0303..cfc48d8 100644 --- a/src/main.py +++ b/src/main.py @@ -37,7 +37,7 @@ def create_app(name: str, config: Config) -> Flask: user_repo = UserRepoImpl(database) email_service = get_email_service(config.email) app.services = AppServices( - users=UserService(user_repo, email_service), + users=UserService(user_repo), auth=AuthService(config.auth), email=email_service, notifications=get_notification_service(email_service), diff --git a/src/services/auth.py b/src/services/auth.py index f89cd9c..ec38c2c 100644 --- a/src/services/auth.py +++ b/src/services/auth.py @@ -1,7 +1,5 @@ -import abc -from src.config.email import EmailAddress from src.config.parse import assert_key_of_type, ParseError -from typing import Any, TypeVar +from typing import Any from dataclasses import dataclass, asdict from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey import jwt @@ -11,53 +9,19 @@ from src.config.auth import Auth as AuthConfig JWT = str -T = TypeVar('T', bound='Claim') - - -# TODO: Do these claims need explicit exp attributes? I think the ID fields should actually be sub now that I think -# about it... -@dataclass -class Claim(abc.ABC): - @classmethod - @abc.abstractmethod - def from_dict(cls, claims: dict[str, Any]) -> T: - pass - - @classmethod - @abc.abstractmethod - def from_user(cls, user: User) -> T: - pass - @dataclass -class UserClaims(Claim): +class Claims: id: int @classmethod - def from_user(cls, user: User) -> 'UserClaims': - return UserClaims(id=user.id) + def from_user(cls, user: User) -> 'Claims': + return Claims(id=user.id) @classmethod - def from_dict(cls, claims: dict[str, Any]) -> 'UserClaims': + def from_dict(cls, claims: dict[str, Any]) -> 'Claims': assert_key_of_type(claims, 'id', int) - return UserClaims(id=claims['id']) - - -@dataclass -class EmailConfirmationClaim(Claim): - id: int - email: EmailAddress - - @classmethod - def from_user(cls, user: User) -> 'EmailConfirmationClaim': - return EmailConfirmationClaim(id=user.id, email=user.email) - - @classmethod - def from_dict(cls, claims: dict[str, Any]) -> 'EmailConfirmationClaim': - assert_key_of_type(claims, 'id', int) - assert_key_of_type(claims, 'email', str) - - return EmailConfirmationClaim(id=claims['id'], email=claims['email']) + return Claims(id=claims['id']) class AuthService: @@ -65,17 +29,17 @@ class AuthService: self._private_key = Ed25519PrivateKey.from_private_bytes(config.ed25519_private_key.expose_secret()) self._public_key = self._private_key.public_key() - def mint_claim_from_user(self, claim: type[Claim], user: User) -> JWT: - claims = claim.from_user(user) + def mint_jwt(self, user: User) -> JWT: + claims = Claims.from_user(user) return jwt.encode(asdict(claims), self._private_key, algorithm='EdDSA') - def validate_token[T: Claim](self, claim: type[T], token: JWT) -> T | None: + def validate_token(self, token: JWT) -> Claims | None: try: claims = jwt.decode(token, key=self._public_key, algorithms=['EdDSA']) except jwt.InvalidTokenError: return None try: - return claim.from_dict(claims) + return Claims.from_dict(claims) except ParseError: return None diff --git a/src/services/email.py b/src/services/email.py index efadd05..918bd28 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -7,6 +7,8 @@ from ..config.email import Email as EmailConfig, EmailAddress @dataclass class EmailDTO: + # Who the email is from + sender: EmailAddress # Who the email is to to: EmailAddress # The subject of the email @@ -30,7 +32,7 @@ class BirdEmailServiceImpl(EmailService): self._config = config def send_email(self, email: EmailDTO): - payload = {'from': self._config.sender, 'to': email.to, 'subject': email.subject, 'text': email.text} + payload = {'from': email.sender, 'to': email.to, 'subject': email.subject, 'text': email.text} if email.html: payload['html'] = email.html diff --git a/src/services/users.py b/src/services/users.py new file mode 100644 index 0000000..3132f33 --- /dev/null +++ b/src/services/users.py @@ -0,0 +1,98 @@ +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() diff --git a/src/services/users/__init__.py b/src/services/users/__init__.py deleted file mode 100644 index f733467..0000000 --- a/src/services/users/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .data import User, UserProfile, UserDTO, SignupError, LoginError -from .service import UserService -from .repo import UserRepo - -__all__ = ['User', 'UserProfile', 'UserDTO', 'SignupError', 'LoginError', 'UserService', 'UserRepo'] diff --git a/src/services/users/data.py b/src/services/users/data.py deleted file mode 100644 index cd96b07..0000000 --- a/src/services/users/data.py +++ /dev/null @@ -1,34 +0,0 @@ -from src.config.email import EmailAddress -from dataclasses import dataclass -from src.utils.secret import SecretBox - - -@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 diff --git a/src/services/users/repo.py b/src/services/users/repo.py deleted file mode 100644 index f50836b..0000000 --- a/src/services/users/repo.py +++ /dev/null @@ -1,26 +0,0 @@ -import abc -from src.config.email import EmailAddress - -from .data import User, UserDTO - - -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 diff --git a/src/services/users/service.py b/src/services/users/service.py deleted file mode 100644 index 957fd14..0000000 --- a/src/services/users/service.py +++ /dev/null @@ -1,95 +0,0 @@ -from src.services.auth import AuthService, EmailConfirmationClaim, JWT -from email.headerregistry import Address -from jinja2 import Environment, PackageLoader, select_autoescape - -from src.services.email import EmailService, EmailDTO -from .data import UserDTO, User, UserProfile, SignupError, LoginError -from .repo import UserRepo - - -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, email_service: EmailService, auth_service: AuthService): - self._repo = repo - self._email_service = email_service - self._auth_service = auth_service - - def login(self, user: UserDTO) -> UserProfile: - full_user = self._repo.auth_as_user(user) - if full_user: - return full_user.to_profile() - else: - raise LoginError('No user found for that email or password') - - def confirm_email_for_user(self, user_id: int, confirmation_token: JWT) -> bool: - """ - Attempt to confirm that the user at the given ID has confirmed their email by returning the JWT that was - minted for this purpose. - - Returns whether or not the confirmation was performed. - """ - claim = self._auth_service.validate_token(EmailConfirmationClaim, confirmation_token) - - # First check that the claim could be parsed and that it refers to the expected user - if claim and claim.id == user_id: - user = self._repo.get_user_by_id(user_id) - # Double check that: - # 1. The user exists in the database - # 2. The claim refers to the email address on file - # 3. The email was not already confirmed - if user and user.email == claim.email and not user.email_confirmed: - user.email_confirmed = True - self._repo.update_user(user) - return True - - # In all other cases, the confirmation was not possible so return False - return False - - def _send_confirmation_email(self, user: User): - env = Environment(loader=PackageLoader('src'), autoescape=select_autoescape()) - text_template = env.get_template('mail/confirmation_email.txt') - html_template = env.get_template('mail/confirmation_email.html') - token = self._auth_service.mint_claim_from_user(EmailConfirmationClaim, user) - - # TODO: Parameterize this with configuration that also drives the API - url = f'/confirm?token={token}' - text_content = text_template.render(confirmation_link=url) - html_content = html_template.render(confirmation_link=url) - - email = EmailDTO(to=user.email, subject='Confirm Your Email Address', text=text_content, html=html_content) - - self._email_service.send_email(email) - - 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') - - # Create a user in persistence - created_user = self._repo.create_user(user) - # send a confirmation email - self._send_confirmation_email(created_user) - - return created_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() diff --git a/src/templates/mail/email_confirmation.html b/src/templates/mail/email_confirmation.html deleted file mode 100644 index 86545cf..0000000 --- a/src/templates/mail/email_confirmation.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - -
📨
-

Confirm your Email Address

-

Please use the following link to confirm your email address.

-
- {{confirmation_link}} -
-

If you did not create a 🥣 Cereal account for this email address, please ignore this email.

-
- - diff --git a/src/templates/mail/email_confirmation.txt b/src/templates/mail/email_confirmation.txt deleted file mode 100644 index b7bb865..0000000 --- a/src/templates/mail/email_confirmation.txt +++ /dev/null @@ -1,7 +0,0 @@ -📨 Confirm Your Email Address - - -Please use the following link to confirm your email address. -{{confirmation_link}} - -If you did not create a 🥣 Cereal account for this email address, please ignore this email.