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

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