Add users

This commit is contained in:
Campbell Alden 2026-08-01 01:09:51 +09:00
parent 6700bc9763
commit 12797e3cae
7 changed files with 194 additions and 2 deletions

View file

@ -6,6 +6,10 @@
} }
"email": { "email": {
"bird_api_key": "asdf1234", "bird_api_key": "asdf1234",
"sender": "me@example.com" "sender": "noreply@verifiedsenderdomain.com"
},
"database": {
"url": "sqlite://",
"echo": false
} }
} }

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]; propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi];
src = ./.; src = ./.;
pyproject = true; pyproject = true;
build-system = [setuptools]; build-system = [setuptools];

View file

@ -4,6 +4,8 @@ let
requests requests
flask flask
waitress waitress
sqlalchemy
argon2-cffi
]); ]);
in in
with pkgs; with pkgs;

0
src/infra/__init__.py Normal file
View file

84
src/infra/users.py Normal file
View file

@ -0,0 +1,84 @@
import logging
from argon2.exceptions import VerifyMismatchError
from src.utils.secret import SecretBox
from src.config.email import EmailAddress
from sqlalchemy import String, Boolean, VARCHAR, Engine, select
from sqlalchemy.orm import mapped_column, Mapped, Session
from argon2 import PasswordHasher
from src.infra.db import Base
from src.services.users import UserRepo, UserDTO, User
class UserModel(Base):
__tablename__ = 'user'
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(254), unique=True)
email_confirmed: Mapped[bool] = mapped_column(Boolean())
password_hash: Mapped[str] = mapped_column(VARCHAR(255))
def model_to_user(db_user: UserModel) -> User:
return User(
id=db_user.id,
email=db_user.email,
email_confirmed=db_user.email_confirmed,
password_hash=SecretBox(db_user.password_hash),
)
class UserRepoImpl(UserRepo):
def __init__(self, db: Engine):
self._db = db
self._hasher = PasswordHasher()
self._logger = logging.getLogger('UserRepoImpl')
def create_user(self, user: UserDTO) -> User:
pw = self._hasher.hash(user.raw_password.expose_secret())
db_user = UserModel(email=user.email, password_hash=pw)
with Session(self._db) as session:
session.add(db_user)
session.commit()
return model_to_user(db_user)
def get_user_by_id(self, user_id: int) -> User | None:
with Session(self._db) as session:
user = session.get(UserModel, user_id)
if user:
return model_to_user(user)
def get_user_by_email(self, email: EmailAddress) -> User | None:
with Session(self._db) as session:
user = session.scalar(select(UserModel).where(UserModel.email == email))
if user:
return model_to_user(user)
def update_user(self, user: User):
with Session(self._db) as session:
db_user = session.get(UserModel, user.id)
if db_user is None:
self._logger.warning(f'Attempted to update non-existing user {user.id}')
return
# These fields can be set directly
db_user.email_confirmed = user.email_confirmed
db_user.password_hash = user.password_hash.expose_secret()
# If the email is being updated then it should not be considered confirmed.
if db_user.email != user.email:
db_user.email = user.email
db_user.email_confirmed = False
session.commit()
def auth_as_user(self, user: UserDTO) -> User | None:
full_user = self.get_user_by_email(user.email)
if full_user is None:
return None
try:
self._hasher.verify(full_user.password_hash.expose_secret(), user.raw_password.expose_secret())
except VerifyMismatchError:
return None

0
src/services/__init__.py Normal file
View file

102
src/services/users.py Normal file
View file

@ -0,0 +1,102 @@
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()
def get_service(repo: UserRepo) -> UserService:
return UserService(repo)