Add users
This commit is contained in:
parent
6700bc9763
commit
12797e3cae
7 changed files with 194 additions and 2 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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
0
src/infra/__init__.py
Normal file
84
src/infra/users.py
Normal file
84
src/infra/users.py
Normal 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
0
src/services/__init__.py
Normal file
102
src/services/users.py
Normal file
102
src/services/users.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue