Compare commits
No commits in common. "782e0b0ad6d0dfd8a41ac20a7c44d75bd94a6de4" and "72ece8af72d3d7e21b84c041a0a3c02331e49168" have entirely different histories.
782e0b0ad6
...
72ece8af72
13 changed files with 114 additions and 244 deletions
|
|
@ -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 jinja2];
|
propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography];
|
||||||
src = ./.;
|
src = ./.;
|
||||||
pyproject = true;
|
pyproject = true;
|
||||||
build-system = [setuptools];
|
build-system = [setuptools];
|
||||||
|
|
|
||||||
1
setup.py
1
setup.py
|
|
@ -5,6 +5,5 @@ 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'],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ let
|
||||||
argon2-cffi
|
argon2-cffi
|
||||||
pyjwt
|
pyjwt
|
||||||
cryptography
|
cryptography
|
||||||
jinja2
|
|
||||||
]);
|
]);
|
||||||
in
|
in
|
||||||
with pkgs;
|
with pkgs;
|
||||||
|
|
@ -21,5 +20,6 @@ mkShell {
|
||||||
python313Packages.python-lsp-server
|
python313Packages.python-lsp-server
|
||||||
python313Packages.jedi-language-server
|
python313Packages.jedi-language-server
|
||||||
ty
|
ty
|
||||||
|
ffmpeg
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, email_service),
|
users=UserService(user_repo),
|
||||||
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),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
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, TypeVar
|
from typing import Any
|
||||||
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
|
||||||
|
|
@ -11,53 +9,19 @@ 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 UserClaims(Claim):
|
class Claims:
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_user(cls, user: User) -> 'UserClaims':
|
def from_user(cls, user: User) -> 'Claims':
|
||||||
return UserClaims(id=user.id)
|
return Claims(id=user.id)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, claims: dict[str, Any]) -> 'UserClaims':
|
def from_dict(cls, claims: dict[str, Any]) -> 'Claims':
|
||||||
assert_key_of_type(claims, 'id', int)
|
assert_key_of_type(claims, 'id', int)
|
||||||
return UserClaims(id=claims['id'])
|
return Claims(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:
|
||||||
|
|
@ -65,17 +29,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_claim_from_user(self, claim: type[Claim], user: User) -> JWT:
|
def mint_jwt(self, user: User) -> JWT:
|
||||||
claims = claim.from_user(user)
|
claims = Claims.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[T: Claim](self, claim: type[T], token: JWT) -> T | None:
|
def validate_token(self, token: JWT) -> Claims | 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 claim.from_dict(claims)
|
return Claims.from_dict(claims)
|
||||||
except ParseError:
|
except ParseError:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ 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
|
||||||
|
|
@ -30,7 +32,7 @@ class BirdEmailServiceImpl(EmailService):
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
||||||
def send_email(self, email: EmailDTO):
|
def send_email(self, email: EmailDTO):
|
||||||
payload = {'from': self._config.sender, 'to': email.to, 'subject': email.subject, 'text': email.text}
|
payload = {'from': email.sender, 'to': email.to, 'subject': email.subject, 'text': email.text}
|
||||||
|
|
||||||
if email.html:
|
if email.html:
|
||||||
payload['html'] = email.html
|
payload['html'] = email.html
|
||||||
|
|
|
||||||
98
src/services/users.py
Normal file
98
src/services/users.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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()
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from .data import User, UserProfile, UserDTO, SignupError, LoginError
|
|
||||||
from .service import UserService
|
|
||||||
from .repo import UserRepo
|
|
||||||
|
|
||||||
__all__ = ['User', 'UserProfile', 'UserDTO', 'SignupError', 'LoginError', 'UserService', 'UserRepo']
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
📨 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.
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue