Move some items, fix some programming issues, start work on logging in

This commit is contained in:
2025-01-18 01:40:26 +02:00
parent 724d9988d2
commit 0f0b0f812a
10 changed files with 249 additions and 220 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
from passlib.context import CryptContext # type: ignore
import pytz import pytz
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -7,11 +8,12 @@ class Settings(BaseSettings):
PROJECT_VERSION: str = "0.0.1" PROJECT_VERSION: str = "0.0.1"
PROJECT_SUMMARY: str = "Product API for Open Asset Manager." PROJECT_SUMMARY: str = "Product API for Open Asset Manager."
SECRET_KEY: str | None = None SECRET_KEY: str | None = None
HASHING_SCHEME: str = "HS256" HASHING_SCHEME: str = "HS512"
PSQL_CONNECT_STR: str | None = None PSQL_CONNECT_STR: str | None = None
ACCESS_TOKEN_EXPIRE_MIN: int = 30 ACCESS_TOKEN_EXPIRE_MIN: int = 30
REFRESH_TOKEN_EXPIRE_MIN: int = 60 REFRESH_TOKEN_EXPIRE_MIN: int = 60
DEFAULT_TIMEZONE: str = pytz.UTC._tzname DEFAULT_TIMEZONE: str = pytz.UTC._tzname
CRYPT: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto")
model_config = SettingsConfigDict(env_file=".env") model_config = SettingsConfigDict(env_file=".env")
+1 -1
View File
@@ -1,6 +1,6 @@
from typing_extensions import Any from typing_extensions import Any
from tortoise import Tortoise from tortoise import Tortoise
from src.config import settings from config import settings
db_url = settings.PSQL_CONNECT_STR db_url = settings.PSQL_CONNECT_STR
modules: dict[str, Any] = { modules: dict[str, Any] = {
+8 -3
View File
@@ -1,9 +1,10 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import JSONResponse
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
from src.config import settings from config import settings
from src.modules.assets.router import router as asset_router from modules.assets.router import router as asset_router
from tortoise.contrib.fastapi import register_tortoise from tortoise.contrib.fastapi import register_tortoise
from src.database import db_url, modules from database import db_url, modules
app = FastAPI( app = FastAPI(
title=settings.PROJECT_NAME, title=settings.PROJECT_NAME,
@@ -24,3 +25,7 @@ app.include_router(asset_router)
@app.get("/") @app.get("/")
async def main(): async def main():
return RedirectResponse(url="/docs") return RedirectResponse(url="/docs")
@app.get("/ping")
async def ping() -> JSONResponse:
return JSONResponse("PONG")
-205
View File
@@ -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 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: class CMDMixin:
@@ -64,153 +9,3 @@ class CMDMixin:
created_at = fields.DatetimeField(null=True, auto_now_add=True) created_at = fields.DatetimeField(null=True, auto_now_add=True)
modified_at = fields.DatetimeField(null=True, auto_now=True) modified_at = fields.DatetimeField(null=True, auto_now=True)
disabled_at = fields.DatetimeField(null=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()
@@ -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()
+14 -10
View File
@@ -1,12 +1,12 @@
import os from typing import Annotated
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.security import OAuth2PasswordBearer
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from src.modules.auth.schemas import UserModel from modules.users.models import User
from src.models import User from fastapi import Depends, HTTPException
from fastapi import HTTPException from config import settings
from src.config import settings from tortoise.expressions import Q
from authlib.jose import jwt # type: ignore
router = APIRouter(prefix="/auth") 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" error: str = "E-Mail Address or password is incorrect"
crypt = settings.CRYPT
@router.post("/") @router.post("/")
async def login(email: str, password: str): async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
user: User = await User.get_or_none(email=email) user: User = await User.filter(Q(email=form.username)).get_or_none()
if user is None: if user is None:
HTTPException(status_code=401, detail=error) 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) HTTPException(status_code=401, detail=error)
@@ -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()
@@ -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()