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 {
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];

View file

@ -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'],
)

View file

@ -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
];
}

View file

@ -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),
users=UserService(user_repo, email_service),
auth=AuthService(config.auth),
email=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 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

View file

@ -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

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.