Compare commits

..

3 commits

Author SHA1 Message Date
Campbell Alden
782e0b0ad6 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")
2026-08-03 00:33:12 +09:00
Campbell Alden
ecb98dac53 Fixup user service initiation in main 2026-08-03 00:01:29 +09:00
Campbell Alden
395164f630 Move users service into a module 2026-08-03 00:01:15 +09:00
13 changed files with 244 additions and 114 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

@ -37,7 +37,7 @@ def create_app(name: str, config: Config) -> Flask:
user_repo = UserRepoImpl(database) user_repo = UserRepoImpl(database)
email_service = get_email_service(config.email) email_service = get_email_service(config.email)
app.services = AppServices( app.services = AppServices(
users=UserService(user_repo), users=UserService(user_repo, email_service),
auth=AuthService(config.auth), auth=AuthService(config.auth),
email=email_service, email=email_service,
notifications=get_notification_service(email_service), notifications=get_notification_service(email_service),

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,98 +0,0 @@
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()

View file

@ -0,0 +1,5 @@
from .data import User, UserProfile, UserDTO, SignupError, LoginError
from .service import UserService
from .repo import UserRepo
__all__ = ['User', 'UserProfile', 'UserDTO', 'SignupError', 'LoginError', 'UserService', 'UserRepo']

View file

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

View file

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

View file

@ -0,0 +1,95 @@
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()

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.