diff --git a/derivation.nix b/derivation.nix index 639f015..241000d 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]; + propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography jinja2]; src = ./.; pyproject = true; build-system = [setuptools]; diff --git a/setup.py b/setup.py index af47279..d244a90 100644 --- a/setup.py +++ b/setup.py @@ -5,5 +5,6 @@ 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 50007eb..585c993 100644 --- a/shell.nix +++ b/shell.nix @@ -8,6 +8,7 @@ let argon2-cffi pyjwt cryptography + jinja2 ]); in with pkgs; @@ -20,6 +21,5 @@ mkShell { python313Packages.python-lsp-server python313Packages.jedi-language-server ty - ffmpeg ]; } diff --git a/src/services/auth.py b/src/services/auth.py index ec38c2c..f89cd9c 100644 --- a/src/services/auth.py +++ b/src/services/auth.py @@ -1,5 +1,7 @@ +import abc +from src.config.email import EmailAddress from src.config.parse import assert_key_of_type, ParseError -from typing import Any +from typing import Any, TypeVar from dataclasses import dataclass, asdict from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey import jwt @@ -9,19 +11,53 @@ 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 Claims: +class UserClaims(Claim): id: int @classmethod - def from_user(cls, user: User) -> 'Claims': - return Claims(id=user.id) + def from_user(cls, user: User) -> 'UserClaims': + return UserClaims(id=user.id) @classmethod - def from_dict(cls, claims: dict[str, Any]) -> 'Claims': + def from_dict(cls, claims: dict[str, Any]) -> 'UserClaims': assert_key_of_type(claims, 'id', int) - return Claims(id=claims['id']) + 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']) class AuthService: @@ -29,17 +65,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_jwt(self, user: User) -> JWT: - claims = Claims.from_user(user) + def mint_claim_from_user(self, claim: type[Claim], user: User) -> JWT: + claims = claim.from_user(user) return jwt.encode(asdict(claims), self._private_key, algorithm='EdDSA') - def validate_token(self, token: JWT) -> Claims | None: + def validate_token[T: Claim](self, claim: type[T], token: JWT) -> T | None: try: claims = jwt.decode(token, key=self._public_key, algorithms=['EdDSA']) except jwt.InvalidTokenError: return None try: - return Claims.from_dict(claims) + return claim.from_dict(claims) except ParseError: return None diff --git a/src/services/email.py b/src/services/email.py index 918bd28..efadd05 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -7,8 +7,6 @@ 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 @@ -32,7 +30,7 @@ class BirdEmailServiceImpl(EmailService): self._config = config def send_email(self, email: EmailDTO): - payload = {'from': email.sender, 'to': email.to, 'subject': email.subject, 'text': email.text} + payload = {'from': self._config.sender, 'to': email.to, 'subject': email.subject, 'text': email.text} if email.html: payload['html'] = email.html diff --git a/src/services/users/service.py b/src/services/users/service.py index f843fbe..957fd14 100644 --- a/src/services/users/service.py +++ b/src/services/users/service.py @@ -1,6 +1,9 @@ -from src.services.email import EmailService +from src.services.auth import AuthService, EmailConfirmationClaim, JWT from email.headerregistry import Address -from .data import UserDTO, UserProfile, SignupError, LoginError +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 @@ -21,9 +24,10 @@ def is_valid_password(password: str) -> bool: class UserService: - def __init__(self, repo: UserRepo, email_service: EmailService): + 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) @@ -32,6 +36,45 @@ class UserService: 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') @@ -40,9 +83,11 @@ class UserService: raise SignupError('The given password was not acceptable') # Create a user in persistence - created_user = self._repo.create_user(user).to_profile() + created_user = self._repo.create_user(user) + # send a confirmation email + self._send_confirmation_email(created_user) - return 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) diff --git a/src/templates/mail/email_confirmation.html b/src/templates/mail/email_confirmation.html new file mode 100644 index 0000000..86545cf --- /dev/null +++ b/src/templates/mail/email_confirmation.html @@ -0,0 +1,26 @@ + + +
+| 📨 | +
+ 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. + |
+