Add in a mechanism for email confirmation

This is some of the way there but it is still missing at least:
- An actual page to view to confirm the email
- An expiry time on the minted JWT (and other JWT issues like "sub")
This commit is contained in:
Campbell Alden 2026-08-03 00:33:12 +09:00
parent ecb98dac53
commit 782e0b0ad6
8 changed files with 133 additions and 20 deletions

View file

@ -3,7 +3,7 @@ with python313Packages;
buildPythonApplication { buildPythonApplication {
pname = "cereal"; pname = "cereal";
version = "0.0.1"; version = "0.0.1";
propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography]; propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography jinja2];
src = ./.; src = ./.;
pyproject = true; pyproject = true;
build-system = [setuptools]; build-system = [setuptools];

View file

@ -5,5 +5,6 @@ setup(
verison='0.0.1', verison='0.0.1',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
package_data={'src': ['templates/**/*.html', 'templates/**/*.txt']},
scripts=['./src/main.py'], scripts=['./src/main.py'],
) )

View file

@ -8,6 +8,7 @@ let
argon2-cffi argon2-cffi
pyjwt pyjwt
cryptography cryptography
jinja2
]); ]);
in in
with pkgs; with pkgs;
@ -20,6 +21,5 @@ mkShell {
python313Packages.python-lsp-server python313Packages.python-lsp-server
python313Packages.jedi-language-server python313Packages.jedi-language-server
ty ty
ffmpeg
]; ];
} }

View file

@ -1,5 +1,7 @@
import abc
from src.config.email import EmailAddress
from src.config.parse import assert_key_of_type, ParseError 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 dataclasses import dataclass, asdict
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import jwt import jwt
@ -9,19 +11,53 @@ from src.config.auth import Auth as AuthConfig
JWT = str 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 @dataclass
class Claims: class UserClaims(Claim):
id: int id: int
@classmethod @classmethod
def from_user(cls, user: User) -> 'Claims': def from_user(cls, user: User) -> 'UserClaims':
return Claims(id=user.id) return UserClaims(id=user.id)
@classmethod @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) 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: class AuthService:
@ -29,17 +65,17 @@ class AuthService:
self._private_key = Ed25519PrivateKey.from_private_bytes(config.ed25519_private_key.expose_secret()) self._private_key = Ed25519PrivateKey.from_private_bytes(config.ed25519_private_key.expose_secret())
self._public_key = self._private_key.public_key() self._public_key = self._private_key.public_key()
def mint_jwt(self, user: User) -> JWT: def mint_claim_from_user(self, claim: type[Claim], user: User) -> JWT:
claims = Claims.from_user(user) claims = claim.from_user(user)
return jwt.encode(asdict(claims), self._private_key, algorithm='EdDSA') 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: try:
claims = jwt.decode(token, key=self._public_key, algorithms=['EdDSA']) claims = jwt.decode(token, key=self._public_key, algorithms=['EdDSA'])
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
return None return None
try: try:
return Claims.from_dict(claims) return claim.from_dict(claims)
except ParseError: except ParseError:
return None return None

View file

@ -7,8 +7,6 @@ from ..config.email import Email as EmailConfig, EmailAddress
@dataclass @dataclass
class EmailDTO: class EmailDTO:
# Who the email is from
sender: EmailAddress
# Who the email is to # Who the email is to
to: EmailAddress to: EmailAddress
# The subject of the email # The subject of the email
@ -32,7 +30,7 @@ class BirdEmailServiceImpl(EmailService):
self._config = config self._config = config
def send_email(self, email: EmailDTO): 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: if email.html:
payload['html'] = email.html payload['html'] = email.html

View file

@ -1,6 +1,9 @@
from src.services.email import EmailService from src.services.auth import AuthService, EmailConfirmationClaim, JWT
from email.headerregistry import Address 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 from .repo import UserRepo
@ -21,9 +24,10 @@ def is_valid_password(password: str) -> bool:
class UserService: 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._repo = repo
self._email_service = email_service self._email_service = email_service
self._auth_service = auth_service
def login(self, user: UserDTO) -> UserProfile: def login(self, user: UserDTO) -> UserProfile:
full_user = self._repo.auth_as_user(user) full_user = self._repo.auth_as_user(user)
@ -32,6 +36,45 @@ class UserService:
else: else:
raise LoginError('No user found for that email or password') 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: def signup(self, user: UserDTO) -> UserProfile:
if not is_valid_email(user.email): if not is_valid_email(user.email):
raise SignupError(f'{user.email} was not an acceptable email address') 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') raise SignupError('The given password was not acceptable')
# Create a user in persistence # 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: def get_user_by_id(self, user_id: int) -> UserProfile | None:
user = self._repo.get_user_by_id(user_id) user = self._repo.get_user_by_id(user_id)

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<body>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-size: 4rem; padding: 0 0 24px 0; text-align: center; vertical-align: middle;"><span aria-hidden>📨</span></td>
</tr>
<tr>
<td style="padding: 0 0 24px 0; text-align: center; vertical-align: middle; width:536px; max-width: 100%;">
<h1 style="margin: 0 0 12px 0;">Confirm your Email Address</h1>
<p style="margin-left: auto; margin-right: auto; margin-top: 0; margin-bottom: 8px; max-width: 65ch">Please use the following link to confirm your email address.</p>
</td>
</tr>
<tr>
<td style="padding: 0 0 24px 0; text-align: center; vertical-align: middle; width:536px; max-width: 100%;">
<a href="{{confirmation_link}}" style="max-width: 65ch">{{confirmation_link}}</a>
</td>
</tr>
<tr>
<td style="padding: 0 0 24px 0; text-align: center; vertical-align: middle; width:536px; max-width: 100%;">
<p style="margin: 0 auto; max-width: 65ch">If you did not create a <span aria-hidden>🥣</span> <b>Cereal</b> account for this email address, please ignore this email.</p>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,7 @@
📨 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.