Move some items, fix some programming issues, start work on logging in
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user