From 0f0b0f812a96eb8d3915f783fd485743f6034efe Mon Sep 17 00:00:00 2001 From: Jeroen Vijgen Date: Sat, 18 Jan 2025 01:40:26 +0200 Subject: [PATCH] Move some items, fix some programming issues, start work on logging in --- api/asset_manager/src/config.py | 4 +- api/asset_manager/src/database.py | 2 +- api/asset_manager/src/main.py | 11 +- api/asset_manager/src/models.py | 205 ------------------ api/asset_manager/src/modules/auth/models.py | 27 +++ api/asset_manager/src/modules/auth/router.py | 24 +- .../src/modules/organizations/models.py | 86 ++++++++ api/asset_manager/src/modules/users/models.py | 110 ++++++++++ api/asset_manager/src/modules/users/router.py | 0 .../src/modules/users/schemas.py | 0 10 files changed, 249 insertions(+), 220 deletions(-) create mode 100644 api/asset_manager/src/modules/organizations/models.py create mode 100644 api/asset_manager/src/modules/users/models.py create mode 100644 api/asset_manager/src/modules/users/router.py create mode 100644 api/asset_manager/src/modules/users/schemas.py diff --git a/api/asset_manager/src/config.py b/api/asset_manager/src/config.py index aa57c6e7..6674fae1 100644 --- a/api/asset_manager/src/config.py +++ b/api/asset_manager/src/config.py @@ -1,5 +1,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore +from passlib.context import CryptContext # type: ignore import pytz class Settings(BaseSettings): @@ -7,11 +8,12 @@ class Settings(BaseSettings): PROJECT_VERSION: str = "0.0.1" PROJECT_SUMMARY: str = "Product API for Open Asset Manager." SECRET_KEY: str | None = None - HASHING_SCHEME: str = "HS256" + HASHING_SCHEME: str = "HS512" PSQL_CONNECT_STR: str | None = None ACCESS_TOKEN_EXPIRE_MIN: int = 30 REFRESH_TOKEN_EXPIRE_MIN: int = 60 DEFAULT_TIMEZONE: str = pytz.UTC._tzname + CRYPT: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto") model_config = SettingsConfigDict(env_file=".env") diff --git a/api/asset_manager/src/database.py b/api/asset_manager/src/database.py index 38523e41..a72f712d 100644 --- a/api/asset_manager/src/database.py +++ b/api/asset_manager/src/database.py @@ -1,6 +1,6 @@ from typing_extensions import Any from tortoise import Tortoise -from src.config import settings +from config import settings db_url = settings.PSQL_CONNECT_STR modules: dict[str, Any] = { diff --git a/api/asset_manager/src/main.py b/api/asset_manager/src/main.py index 1d4b1cb7..41229ed8 100644 --- a/api/asset_manager/src/main.py +++ b/api/asset_manager/src/main.py @@ -1,9 +1,10 @@ from fastapi import FastAPI +from fastapi.responses import JSONResponse from starlette.responses import RedirectResponse -from src.config import settings -from src.modules.assets.router import router as asset_router +from config import settings +from modules.assets.router import router as asset_router from tortoise.contrib.fastapi import register_tortoise -from src.database import db_url, modules +from database import db_url, modules app = FastAPI( title=settings.PROJECT_NAME, @@ -24,3 +25,7 @@ app.include_router(asset_router) @app.get("/") async def main(): return RedirectResponse(url="/docs") + +@app.get("/ping") +async def ping() -> JSONResponse: + return JSONResponse("PONG") \ No newline at end of file diff --git a/api/asset_manager/src/models.py b/api/asset_manager/src/models.py index 33505b8d..5edfdc3a 100644 --- a/api/asset_manager/src/models.py +++ b/api/asset_manager/src/models.py @@ -1,59 +1,4 @@ -from datetime import datetime -from enum import Enum -from typing import Type -import uuid -from pydantic import EmailStr -from tortoise.exceptions import ConfigurationError -from tortoise.models import Model from tortoise import fields -from passlib.context import CryptContext # type: ignore - -from src.config import settings - -crypt = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -class EnumField(fields.CharField): - """ - Serializes Enums to and from a str representation in the DB. - """ - - def __init__(self, enum_type: Type[Enum], **kwargs): - super().__init__(128, **kwargs) - if not issubclass(enum_type, Enum): - raise ConfigurationError("{} is not a subclass of Enum!".format(enum_type)) - self._enum_type = enum_type - - def to_db_value(self, value: Enum, instance) -> str: - return value.value - - def to_python_value(self, value: str) -> Enum: - try: - return self._enum_type(value) - except Exception: - raise ValueError( - "Database value {} does not exist on Enum {}.".format( - value, self._enum_type - ) - ) - - -class OrganizationType(Enum): - """ - Represents the following: - - 1. Is this a commercial entity or not? - 2. What size is it? - - All choices should be representative of the org. - There are no seat costs. - """ - - HOME: int = 1 # Home use (Any size) - SMALL_ORGANIZATION: int = 2 # 1-100 - MEDIUM_ORGANIZATION: int = 3 # 100 - 500 - LARGE_ORGANIZATION: int = 4 # 500 - 1000 - EXTRA_LARGE_ORGANIZATION: int = 5 # 1000 - 5000+ class CMDMixin: @@ -64,153 +9,3 @@ class CMDMixin: created_at = fields.DatetimeField(null=True, auto_now_add=True) modified_at = fields.DatetimeField(null=True, auto_now=True) disabled_at = fields.DatetimeField(null=True) - - -class Organization(Model, CMDMixin): - """ - Organization - - This class holds the organization for a household / organization - and makes sure that we can add users. - """ - - id: uuid = fields.UUIDField(primary_key=True) - name: str = fields.CharField(max_length=128) - type: str = EnumField(OrganizationType) - users: uuid = fields.ManyToManyField( - "models.User", - related_name="members", - through="Membership", - forward_key="user_id", - backward_key="organization_id", - null=True, - on_delete=fields.NO_ACTION, - ) - disabled: bool = fields.BooleanField(default=False) - - def __str__(self) -> str: - return f"{self.id} - {self.name}" - - def delete(self) -> None: - self.disabled = True - self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE) - self.save() - - -class Token(Model, CMDMixin): - """ - Token - - Creates the access tokens for the User - """ - - id: uuid = fields.UUIDField(primary_key=True) - user: uuid = fields.ForeignKeyField("models.User") - token_type: str = fields.CharField(max_length=128, default="bearer") - access_token: str = fields.CharField(max_length=128, null=True) - refresh_token: str = fields.CharField(max_length=128, null=True) - disabled: bool = fields.BooleanField(default=False) - - def delete(self) -> None: - self.disabled = True - self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE) - self.save() - - -class User(Model, CMDMixin): - """ - User - - This holds all of our users - """ - - id: uuid = fields.UUIDField(primary_key=True) - email: EmailStr = fields.CharField(max_length=128) - username: str = fields.TextField(max_length=128) - name: str = fields.TextField(max_length=128) - surname: str = fields.TextField(max_length=128) - password: str = fields.CharField(max_length=128, null=True) - organizations: uuid = fields.ManyToManyField( - "models.Organization", - related_name="members", - through="Membership", - forward_key="organization_id", - backward_key="user_id", - null=True, - on_delete=fields.NO_ACTION, - ) - disabled: bool = fields.BooleanField(default=False) - tokens = fields.ForeignKeyField("models.Token") - - def __str__(self) -> str: - return f"{self.id} - {self.name} {self.surname}" - - def set_password(self, password: str) -> None: - secret_key = settings.SECRET_KEY - if secret_key is None: - return False - self.password = crypt.hash( - password, - secret=secret_key, - scheme=settings.HASHING_SCHEME, - ) - self.save() # Make sure to save the model in DB - - def check_against_password(self, password: str) -> bool: - secret_key = settings.SECRET_KEY - if secret_key is None: - return False - return crypt.verify( - password, - secret=secret_key, - scheme=settings.HASHING_SCHEME, - ) - - def delete(self) -> None: - self.disabled = True - self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE) - self.save() - - -class ACL(Model): - """ - ACL - - Access control lists, every invited user gets an ACL and this decides whether you grant / deny access to certain parts of our system. - """ - - id: uuid = fields.UUIDField(primary_key=True) - READ: bool = fields.BooleanField(default=False) - WRITE: bool = fields.BooleanField(default=False) - REPORT: bool = fields.BooleanField(default=False) - MANAGE: bool = fields.BooleanField(default=False) - ADMIN: bool = fields.BooleanField(default=False) - - def __str__(self) -> str: - return f""" - ID: {self.id}, - READ: {self.READ}, - WRITE: {self.WRITE}, - REPORT: {self.REPORT}, - MANAGE: {self.MANAGE}, - ADMIN: {self.ADMIN} - """ - - -class Membership(Model, CMDMixin): - """ - Membership - - Creates a connection between an user and a company together with an ACL. - """ - - id: uuid = fields.UUIDField(primary_key=True) - organization: Organization = fields.ForeignKeyField("models.Organization") - user: User = fields.ForeignKeyField("models.User") - acl: ACL = fields.ForeignKeyField("models.ACL") - disabled: bool = fields.BooleanField(default=False) - - def delete(self) -> None: - self.disabled = True - self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE) - self.save() diff --git a/api/asset_manager/src/modules/auth/models.py b/api/asset_manager/src/modules/auth/models.py index 8b137891..a0649f77 100644 --- a/api/asset_manager/src/modules/auth/models.py +++ b/api/asset_manager/src/modules/auth/models.py @@ -1 +1,28 @@ +from tortoise.models import Model +from tortoise import fields +import uuid +from datetime import datetime + +from models import CMDMixin +from config import settings + + +class Token(Model, CMDMixin): + """ + Token + + Creates the access tokens for the User + """ + + id: uuid = fields.UUIDField(primary_key=True) + user: uuid = fields.ForeignKeyField("modules.users.models.User") + token_type: str = fields.CharField(max_length=128, default="bearer") + access_token: str = fields.CharField(max_length=128, null=True) + refresh_token: str = fields.CharField(max_length=128, null=True) + disabled: bool = fields.BooleanField(default=False) + + def delete(self) -> None: + self.disabled = True + self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE) + self.save() diff --git a/api/asset_manager/src/modules/auth/router.py b/api/asset_manager/src/modules/auth/router.py index 5e210358..63c6a3de 100644 --- a/api/asset_manager/src/modules/auth/router.py +++ b/api/asset_manager/src/modules/auth/router.py @@ -1,12 +1,12 @@ -import os - -from fastapi.security import OAuth2PasswordBearer +from typing import Annotated +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.routing import APIRouter -from src.modules.auth.schemas import UserModel -from src.models import User -from fastapi import HTTPException -from src.config import settings +from modules.users.models import User +from fastapi import Depends, HTTPException +from config import settings +from tortoise.expressions import Q +from authlib.jose import jwt # type: ignore router = APIRouter(prefix="/auth") @@ -16,13 +16,17 @@ ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MIN error: str = "E-Mail Address or password is incorrect" +crypt = settings.CRYPT + @router.post("/") -async def login(email: str, password: str): - user: User = await User.get_or_none(email=email) +async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): + user: User = await User.filter(Q(email=form.username)).get_or_none() + if user is None: HTTPException(status_code=401, detail=error) - if user.check_against_password(password) is False: + + if user.check_against_password(form.password) is False: HTTPException(status_code=401, detail=error) diff --git a/api/asset_manager/src/modules/organizations/models.py b/api/asset_manager/src/modules/organizations/models.py new file mode 100644 index 00000000..b3464e7e --- /dev/null +++ b/api/asset_manager/src/modules/organizations/models.py @@ -0,0 +1,86 @@ +from datetime import datetime +from enum import Enum +from typing import Type +import uuid +from tortoise.exceptions import ConfigurationError +from tortoise.models import Model +from tortoise import fields + +from models import CMDMixin +from config import settings + +class EnumField(fields.CharField): + """ + Serializes Enums to and from a str representation in the DB. + """ + + def __init__(self, enum_type: Type[Enum], **kwargs): + super().__init__(128, **kwargs) + if not issubclass(enum_type, Enum): + raise ConfigurationError("{} is not a subclass of Enum!".format(enum_type)) + self._enum_type = enum_type + + def to_db_value(self, value: Enum, instance) -> str: + return value.value + + def to_python_value(self, value: str) -> Enum: + try: + return self._enum_type(value) + except Exception: + raise ValueError( + "Database value {} does not exist on Enum {}.".format( + value, self._enum_type + ) + ) + + +class OrganizationType(Enum): + """ + Represents the following: + + 1. Is this a commercial entity or not? + 2. What size is it? + + All choices should be representative of the org. + There are no seat costs. + """ + + HOME: int = 1 # Home use (Any size) + SMALL_ORGANIZATION: int = 2 # 1-100 + MEDIUM_ORGANIZATION: int = 3 # 100 - 500 + LARGE_ORGANIZATION: int = 4 # 500 - 1000 + EXTRA_LARGE_ORGANIZATION: int = 5 # 1000 - 5000+ + + + +class Organization(Model, CMDMixin): + """ + Organization + + This class holds the organization for a household / organization + and makes sure that we can add users. + """ + + id: uuid = fields.UUIDField(primary_key=True) + name: str = fields.CharField(max_length=128) + type: str = EnumField(OrganizationType) + users: uuid = fields.ManyToManyField( + "models.User", + related_name="members", + through="Membership", + forward_key="user_id", + backward_key="organization_id", + null=True, + on_delete=fields.NO_ACTION, + ) + disabled: bool = fields.BooleanField(default=False) + + def __str__(self) -> str: + return f"{self.id} - {self.name}" + + def delete(self) -> None: + self.disabled = True + self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE) + self.save() + + diff --git a/api/asset_manager/src/modules/users/models.py b/api/asset_manager/src/modules/users/models.py new file mode 100644 index 00000000..9d6ad42d --- /dev/null +++ b/api/asset_manager/src/modules/users/models.py @@ -0,0 +1,110 @@ +from datetime import datetime +import uuid +from pydantic import EmailStr +from tortoise.models import Model +from tortoise import fields + +from modules.organizations.models import Organization +from models import CMDMixin +from config import settings + +crypt = settings.CRYPT + +class User(Model, CMDMixin): + """ + User + + This holds all of our users + """ + + id: uuid = fields.UUIDField(primary_key=True) + email: EmailStr = fields.CharField(max_length=128) + username: str = fields.TextField(max_length=128) + name: str = fields.TextField(max_length=128) + surname: str = fields.TextField(max_length=128) + password: str = fields.CharField(max_length=128, null=True) + organizations: uuid = fields.ManyToManyField( + "models.Organization", + related_name="members", + through="Membership", + forward_key="organization_id", + backward_key="user_id", + null=True, + on_delete=fields.NO_ACTION, + ) + disabled: bool = fields.BooleanField(default=False) + tokens = fields.ForeignKeyField("modules.auth.models.Token") + + def __str__(self) -> str: + return f"{self.id} - {self.name} {self.surname}" + + def set_password(self, password: str) -> None: + self.password = crypt.hash( + password, + settings.HASHING_SCHEME + ) + self.save() # Make sure to save the model in DB + + def check_against_password(self, password: str) -> bool: + return crypt.verify( + password, + self.password, + settings.HASHING_SCHEME + ) + + def update_password(self, old_password, new_password: str, verify_new_password: str) -> bool: + if self.check_against_password(old_password) is False: + return False + if new_password is not verify_new_password: + return False + self.set_password(new_password) + + def delete(self) -> None: + self.disabled = True + self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE) + self.save() + + + +class ACL(Model): + """ + ACL + + Access control lists, every invited user gets an ACL and this decides whether you grant / deny access to certain parts of our system. + """ + + id: uuid = fields.UUIDField(primary_key=True) + READ: bool = fields.BooleanField(default=False) + WRITE: bool = fields.BooleanField(default=False) + REPORT: bool = fields.BooleanField(default=False) + MANAGE: bool = fields.BooleanField(default=False) + ADMIN: bool = fields.BooleanField(default=False) + + def __str__(self) -> str: + return f""" + ID: {self.id}, + READ: {self.READ}, + WRITE: {self.WRITE}, + REPORT: {self.REPORT}, + MANAGE: {self.MANAGE}, + ADMIN: {self.ADMIN} + """ + + +class Membership(Model, CMDMixin): + """ + Membership + + Creates a connection between an user and a company together with an ACL. + """ + + id: uuid = fields.UUIDField(primary_key=True) + organization: Organization = fields.ForeignKeyField("models.Organization") + user: User = fields.ForeignKeyField("models.User") + acl: ACL = fields.ForeignKeyField("models.ACL") + disabled: bool = fields.BooleanField(default=False) + + def delete(self) -> None: + self.disabled = True + self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE) + self.save() diff --git a/api/asset_manager/src/modules/users/router.py b/api/asset_manager/src/modules/users/router.py new file mode 100644 index 00000000..e69de29b diff --git a/api/asset_manager/src/modules/users/schemas.py b/api/asset_manager/src/modules/users/schemas.py new file mode 100644 index 00000000..e69de29b