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