From cdb880e44365bdca9ed512b5b52de510cfa0529c Mon Sep 17 00:00:00 2001 From: Tim Repke <repke@mcc-berlin.net> Date: Tue, 13 Dec 2022 16:58:23 +0100 Subject: [PATCH 1/2] add crud for creating or updating users; move auth stuff to library --- requirements.txt | 4 +- requirements_dev.txt | 3 +- src/nacsos_data/db/crud/users.py | 114 ++++++++++++++++++++++++++----- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/requirements.txt b/requirements.txt index c0c7e23..deb79fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,6 @@ psycopg2==2.9.5 #sqlalchemy[asyncio]==1.4.36 SQLAlchemy==2.0.0b2 sqlalchemy-json==0.5.0 -httpx[http2]==0.23.0 \ No newline at end of file +httpx[http2]==0.23.0 +passlib==1.7.4 +alembic==1.8.1 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 84f2b04..828e948 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,4 +3,5 @@ tox==3.26.0 pytest==7.1.3 pytest-cov==4.0.0 mypy==0.982 -alembic==1.8.1 \ No newline at end of file +alembic==1.8.1 +types-passlib==1.7.7.3 \ No newline at end of file diff --git a/src/nacsos_data/db/crud/users.py b/src/nacsos_data/db/crud/users.py index 747086e..0b41c74 100644 --- a/src/nacsos_data/db/crud/users.py +++ b/src/nacsos_data/db/crud/users.py @@ -1,10 +1,46 @@ +from uuid import uuid4 +from typing import TYPE_CHECKING + from sqlalchemy import select, asc -from uuid import UUID +from passlib.context import CryptContext from nacsos_data.db import DatabaseEngineAsync from nacsos_data.db.schemas import User, ProjectPermissions from nacsos_data.models.users import UserInDBModel, UserModel +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +async def authenticate_user_by_name(username: str, plain_password: str, + engine: DatabaseEngineAsync) -> UserInDBModel | None: + user = await read_user_by_name(username=username, engine=engine) + if not user: + return None + if not verify_password(plain_password, user.password): + return None + return user + + +async def authenticate_user_by_id(user_id: str, plain_password: str, + engine: DatabaseEngineAsync) -> UserInDBModel | None: + user = await read_user_by_id(user_id=user_id, engine=engine) + if not user: + return None + if not verify_password(plain_password, user.password): + return None + return user + async def read_user_by_id(user_id: str, engine: DatabaseEngineAsync) -> UserInDBModel | None: async with engine.session() as session: @@ -32,30 +68,74 @@ async def read_user_by_name(username: str, engine: DatabaseEngineAsync) -> UserI return None -async def read_all_users(engine: DatabaseEngineAsync) -> list[UserInDBModel]: +async def read_users(engine: DatabaseEngineAsync, + project_id: str | None = None, + order_by_username: bool = False) -> list[UserInDBModel] | None: + """ + Returns a list of all users (if `project_id` is None) or a list of users that + are part of a project (have an existing `project_permission` with that `project_id`). + + Optionally, the results will be ordered by username. + + :param engine: async db engine + :param project_id: If not None, results will be filtered to users in this project + :param order_by_username: If true, results will be ordered by username + :return: List of users or None (if applied filter has no response) + """ async with engine.session() as session: stmt = select(User) - result = (await session.execute(stmt)).scalars().all() - return [UserInDBModel(**res.__dict__) for res in result] + if project_id is not None: + stmt.join(ProjectPermissions, ProjectPermissions.user_id == User.user_id) + stmt.where(ProjectPermissions.project_id == project_id) + + if order_by_username: + stmt.order_by(asc(User.username)) -async def read_project_users(project_id: str | UUID, engine: DatabaseEngineAsync) -> list[UserInDBModel] | None: - async with engine.session() as session: - stmt = (select(User) - .join(ProjectPermissions, ProjectPermissions.user_id == User.user_id) - .where(ProjectPermissions.project_id == project_id) - .order_by(asc(User.username))) result = (await session.execute(stmt)).scalars().all() if result is not None: return [UserInDBModel(**res.__dict__) for res in result] -def update_user(user: UserModel) -> str | UUID: - # TODO implement update function - # first check, that ID is set - pass +async def create_or_update_user(user: UserModel | UserInDBModel, engine: DatabaseEngineAsync) -> str: + """ + This updates or saves a user. + Note, that `user_id` and `username` are not editable by this function. This is by design. + + - If `user_id` is empty, one will be added. + - Password will only be updated in the DB if field is not None. + - Password is assumed to be plaintext at this point (yolo) and will be hashed internally. + + :param user: user information + :param engine: async db engine + :return: Returns the `user_id` as string. + """ + + async with engine.session() as session: # type: AsyncSession + user_db: User | None = ( + await session.execute(select(User).where(User.user_id == user.user_id)) + ).scalars().one_or_none() + + if user_db is None: # seems to be a new user + if user.user_id is None: + user.user_id = str(uuid4()) + user_id = str(user.user_id) + session.add(User(**user.dict())) + else: + # user_id -> not editable + # username -> not editable + user_db.email = user.email + user_db.full_name = user.full_name + user_db.affiliation = user.affiliation + user_db.is_active = user.is_active + user_db.is_superuser = user.is_superuser + + password: str | None = getattr(user, 'password', None) + if password is not None: + user_db.password = get_password_hash(password) + user_id = str(user_db.user_id) -def delete_user(uid: str) -> bool: - # TODO delete user with user_id uid - pass + # save changes + await session.commit() + return user_id -- GitLab From d94c1eff46569ba7195408aace43c8559a0042d9 Mon Sep 17 00:00:00 2001 From: Tim Repke <repke@mcc-berlin.net> Date: Thu, 15 Dec 2022 11:06:01 +0100 Subject: [PATCH 2/2] fix user id assignment --- src/nacsos_data/db/crud/users.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nacsos_data/db/crud/users.py b/src/nacsos_data/db/crud/users.py index 0b41c74..5772ba6 100644 --- a/src/nacsos_data/db/crud/users.py +++ b/src/nacsos_data/db/crud/users.py @@ -118,8 +118,8 @@ async def create_or_update_user(user: UserModel | UserInDBModel, engine: Databas if user_db is None: # seems to be a new user if user.user_id is None: - user.user_id = str(uuid4()) - user_id = str(user.user_id) + user_id = str(uuid4()) + user.user_id = user_id session.add(User(**user.dict())) else: # user_id -> not editable -- GitLab