diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..d7b4824c --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +python 3.13.1 +nodejs 23.4.0 diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 00000000..a1ca9103 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,48 @@ +{ + admin off + email {$EMAIL_ADDRESS} + acme_ca https://acme-staging-v02.api.letsencrypt.org/directory + grace_period 30s + shutdown_delay 60s +} + + +(protect) { + @external { + not remote_ip private_ranges + } + abort @external 401 +} + +stoneedge.{$MAIN_DOMAIN} { + handle / { + reverse_proxy stoneedge:8000 + } +} + +stoneedge-staging.{$MAIN_DOMAIN} { + handle / { + import protect + abort + + reverse_proxy stoneedge-staging:8000 + } +} + +{$MAIN_DOMAIN} { + encode zstd gzip + + handle /ping { + @goingDown vars {http.shutting_down} true + respond @goingDown "Shutdown in {http.time_until_shutdown}" 503 + respond "pong" 200 + } + + handle / { + abort 404 + } + + handle { + abort 404 + } +} \ No newline at end of file diff --git a/README.md b/README.md index 25d768f2..6e34ec4f 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,19 @@ # OpenAssetManager Product for asset documentation for home to big business. Free base-system and non-free (paid) addons. -Our folder structure: +## Products + +All of the projects are located in this project. This is a monorepo. Make sure you have ASDF installed. + +### Web + +All web projects are under `web`, they are react projects. + +### API's +Our folder structure for the APIs: ``` fastapi-project -├── alembic/ -├── src -│ ├── auth -│ │ ├── router.py # auth main router with all the endpoints -│ │ ├── schemas.py # pydantic models -│ │ ├── models.py # database models -│ │ ├── dependencies.py # router dependencies -│ │ ├── config.py # local configs -│ │ ├── constants.py # module-specific constants -│ │ ├── exceptions.py # module-specific errors -│ │ ├── service.py # module-specific business logic -│ │ └── utils.py # any other non-business logic functions -│ ├── aws -│ │ ├── client.py # client model for external service communication -│ │ ├── schemas.py -│ │ ├── config.py -│ │ ├── constants.py -│ │ ├── exceptions.py -│ │ └── utils.py -│ └── posts -│ │ ├── router.py -│ │ ├── schemas.py -│ │ ├── models.py -│ │ ├── dependencies.py -│ │ ├── constants.py -│ │ ├── exceptions.py -│ │ ├── service.py -│ │ └── utils.py -│ ├── config.py # global configs -│ ├── models.py # global database models -│ ├── exceptions.py # global exceptions -│ ├── pagination.py # global module e.g. pagination -│ ├── database.py # db connection related stuff -│ └── main.py +├── migrations/ ├── tests/ │ ├── auth │ ├── aws @@ -48,8 +24,39 @@ fastapi-project │ ├── base.txt │ ├── dev.txt │ └── prod.txt +├── router.py # auth main router with all the endpoints +├── schemas.py # pydantic models +├── models.py # database models +│ ├── dependencies.py # router dependencies +│ ├── config.py # local configs +│ ├── constants.py # module-specific constants +│ ├── exceptions.py # module-specific errors +│ ├── service.py # module-specific business logic +│ └── utils.py # any other non-business logic functions +├── aws +│ ├── client.py # client model for external service communication +│ ├── schemas.py +│ ├── config.py +│ ├── constants.py +│ ├── exceptions.py +│ └── utils.py +└── posts +│ ├── router.py +│ ├── schemas.py +│ ├── models.py +│ ├── dependencies.py +│ ├── constants.py +│ ├── exceptions.py +│ ├── service.py +│ └── utils.py +├── config.py # global configs +├── models.py # global database models +├── exceptions.py # global exceptions +├── pagination.py # global module e.g. pagination +├── database.py # db connection related stuff +├── main.py ├── .env ├── .gitignore ├── logging.ini -└── alembic.ini +└── pyproject.toml ``` diff --git a/api/asset_manager/Dockerfile b/api/asset_manager/Dockerfile index 52d8ee71..2a13d927 100644 --- a/api/asset_manager/Dockerfile +++ b/api/asset_manager/Dockerfile @@ -1,17 +1,19 @@ +# Sets up the API before preventing root access to the alpine image FROM python:3.13-alpine -WORKDIR /app +WORKDIR /src + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +COPY ./src/ /src/ + +RUN apk upgrade --no-cache &&\ + uv pip install --system -r /src/requirements/requirements.txt RUN adduser -D nonroot USER nonroot -COPY ./src/ /app/ - -RUN pip install --upgrade pip &&\ - pip install uv &&\ - uv pip install --user --no-cache-dir --upgrade -r /code/requirements/requirements.txt - EXPOSE 8000 ENTRYPOINT ["python", "-m"] -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["fastapi", "run"] diff --git a/api/asset_manager/src/config.py b/api/asset_manager/src/config.py index 1908301d..4f2424bf 100644 --- a/api/asset_manager/src/config.py +++ b/api/asset_manager/src/config.py @@ -1,6 +1,27 @@ -class Settings: - PROJECT_NAME:str = "Open Asset Manager" +from fastapi.security import OAuth2PasswordBearer +from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore +from passlib.context import CryptContext # type: ignore +import pytz + +class Settings(BaseSettings): + PROJECT_NAME: str = "StoneEdge Asset Management System" PROJECT_VERSION: str = "0.0.1" - PROJECT_SUMMARY: str = "Product API for Open Asset Manager." + PROJECT_SUMMARY: str = "Product API for StoneEdge." + PROJECT_PUBLIC_URL: str = "localhost" + SECRET_KEY: str | None = None + PSQL_USERNAME: str = "user" + PSQL_PASSWORD: str = "password" + PSQL_HOSTNAME: str = "localhost" + PSQL_PORT: int = 5432 + PSQL_DB_NAME: str = "stoneedge" + PSQL_TEST_DB_NAME: str = "stoneedge_testing" + ACCESS_TOKEN_EXPIRE_MIN: int = 10 + REFRESH_TOKEN_EXPIRE_MIN: int = 20 + BACKEND_CORS_ORIGINS: list = ["*"] + CRYPT: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto") + OAUTH2_SCHEME: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="token") + + model_config = SettingsConfigDict(env_file=".env") + settings = Settings() diff --git a/api/asset_manager/src/database.py b/api/asset_manager/src/database.py index 655d0ae9..df48d021 100644 --- a/api/asset_manager/src/database.py +++ b/api/asset_manager/src/database.py @@ -1,16 +1,30 @@ from typing_extensions import Any from tortoise import Tortoise -import os +from config import settings +from aerich import Command -db_url = os.getenv('PSQL_CONNECT_STR') -modules: dict[str, Any] = {'models': [ - '.models', - '.modules.auth.models', - '.modules.assets.models', -]} +modules: dict[str, Any] = { + "models": [ + "modules.assets.models", + "modules.auth.models", + "modules.users.models", + "modules.organizations.models", + ] +} TORTOISE_ORM = { - "connections": {"default": db_url}, + "connections": { + "default": { + "engine": "tortoise.backends.asyncpg", + "credentials": { + "host": settings.PSQL_HOSTNAME, + "database": settings.PSQL_DB_NAME, + "user": settings.PSQL_USERNAME, + "password": settings.PSQL_PASSWORD, + "port": settings.PSQL_PORT, + }, + } + }, "apps": { "models": { "models": modules.get("models", []) + ["aerich.models"], @@ -19,14 +33,12 @@ TORTOISE_ORM = { }, } -async def init_db(): - await Tortoise.init( - db_url=db_url, - modules=modules - ) async def migrate_db(): - await init_db() + aerich = Command(tortoise_config=TORTOISE_ORM) + await aerich.init() + await aerich.upgrade(run_in_transaction=True) + await Tortoise.init(config=TORTOISE_ORM) - # Generate the schema - await Tortoise.generate_schemas(safe=True) +async def end_connections_to_db(): + await Tortoise.close_connections() \ No newline at end of file diff --git a/api/asset_manager/src/main.py b/api/asset_manager/src/main.py index d8242546..dae869b3 100644 --- a/api/asset_manager/src/main.py +++ b/api/asset_manager/src/main.py @@ -1,30 +1,49 @@ - from fastapi import FastAPI -from starlette.responses import RedirectResponse -from .config import settings -from .modules.assets.router import router as asset_router -from dotenv import load_dotenv -from tortoise.contrib.fastapi import register_tortoise -from .database import db_url, modules +from config import settings +from database import end_connections_to_db, migrate_db +from responses import msgspec_jsonresponse +from contextlib import asynccontextmanager +from fastapi.middleware.cors import CORSMiddleware + +from router import router as root_router +from modules.assets.router import router as asset_router +from modules.auth.router import router as auth_router +from modules.users.router import router as users_router +from modules.organizations.router import router as organizations_router + +from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware +from fastapi.middleware.trustedhost import TrustedHostMiddleware + +@asynccontextmanager +async def lifespan(_: FastAPI): + await migrate_db() + yield + await end_connections_to_db() -load_dotenv() app = FastAPI( + lifespan=lifespan, title=settings.PROJECT_NAME, version=settings.PROJECT_VERSION, summary=settings.PROJECT_SUMMARY, + default_response_class=msgspec_jsonresponse, ) -register_tortoise( - app, - db_url=db_url, - modules=modules, - generate_schemas=True, - add_exception_handlers=True, -) +app.add_middleware(HTTPSRedirectMiddleware) +app.add_middleware(TrustedHostMiddleware, allowed_hosts=[settings.PROJECT_PUBLIC_URL,]) +# Set all CORS enabled origins +if settings.BACKEND_CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +app.include_router(root_router) +app.include_router(auth_router) +app.include_router(users_router) +app.include_router(organizations_router) app.include_router(asset_router) - -@app.get("/") -async def main(): - return RedirectResponse(url="/docs") diff --git a/api/asset_manager/src/manage.py b/api/asset_manager/src/manage.py new file mode 100644 index 00000000..4c0525a7 --- /dev/null +++ b/api/asset_manager/src/manage.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +from ptpython.repl import embed # type: ignore + +from database import * + +import asyncio + + +async def setup(): + try: + await embed(globals=globals(), return_asyncio_coroutine=True, patch_stdout=True) + except EOFError: + loop.stop() + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + asyncio.ensure_future(setup()) + loop.run_forever() + except KeyboardInterrupt: + pass diff --git a/api/asset_manager/src/migrations/models/0_20250122175143_init.py b/api/asset_manager/src/migrations/models/0_20250122175143_init.py new file mode 100644 index 00000000..88e0a6d0 --- /dev/null +++ b/api/asset_manager/src/migrations/models/0_20250122175143_init.py @@ -0,0 +1,75 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "asset" ( + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(128) NOT NULL +); +CREATE TABLE IF NOT EXISTS "acl" ( + "id" UUID NOT NULL PRIMARY KEY, + "READ" BOOL NOT NULL DEFAULT False, + "WRITE" BOOL NOT NULL DEFAULT False, + "REPORT" BOOL NOT NULL DEFAULT False, + "MANAGE" BOOL NOT NULL DEFAULT False, + "ADMIN" BOOL NOT NULL DEFAULT False +); +COMMENT ON TABLE "acl" IS 'ACL'; +CREATE TABLE IF NOT EXISTS "organization" ( + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(128) NOT NULL, + "type" VARCHAR(128) NOT NULL, + "disabled" BOOL NOT NULL DEFAULT False +); +COMMENT ON TABLE "organization" IS 'Organization'; +CREATE TABLE IF NOT EXISTS "user" ( + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "email" VARCHAR(128) NOT NULL, + "username" TEXT NOT NULL, + "name" TEXT NOT NULL, + "surname" TEXT NOT NULL, + "password" VARCHAR(128), + "disabled" BOOL NOT NULL DEFAULT False +); +COMMENT ON TABLE "user" IS 'User'; +CREATE TABLE IF NOT EXISTS "token" ( + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer', + "access_token" VARCHAR(128), + "refresh_token" VARCHAR(128), + "disabled" BOOL NOT NULL DEFAULT False, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +COMMENT ON TABLE "token" IS 'Token'; +CREATE TABLE IF NOT EXISTS "membership" ( + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "disabled" BOOL NOT NULL DEFAULT False, + "acl_id" UUID NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE, + "organization_id" UUID NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +COMMENT ON TABLE "membership" IS 'Membership'; +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSONB NOT NULL +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ diff --git a/api/asset_manager/src/migrations/models/1_20250123190326_second_try.py b/api/asset_manager/src/migrations/models/1_20250123190326_second_try.py new file mode 100644 index 00000000..c4617ab5 --- /dev/null +++ b/api/asset_manager/src/migrations/models/1_20250123190326_second_try.py @@ -0,0 +1,15 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "asset" ADD "disabled_at" TIMESTAMPTZ; + ALTER TABLE "asset" ADD "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP; + ALTER TABLE "asset" ADD "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "asset" DROP COLUMN "disabled_at"; + ALTER TABLE "asset" DROP COLUMN "modified_at"; + ALTER TABLE "asset" DROP COLUMN "created_at";""" diff --git a/api/asset_manager/src/migrations/models/2_20250214131414_update.py b/api/asset_manager/src/migrations/models/2_20250214131414_update.py new file mode 100644 index 00000000..3b9f1a5e --- /dev/null +++ b/api/asset_manager/src/migrations/models/2_20250214131414_update.py @@ -0,0 +1,13 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "token" ALTER COLUMN "refresh_token" TYPE TEXT USING "refresh_token"::TEXT; + ALTER TABLE "token" ALTER COLUMN "access_token" TYPE TEXT USING "access_token"::TEXT;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "token" ALTER COLUMN "refresh_token" TYPE VARCHAR(128) USING "refresh_token"::VARCHAR(128); + ALTER TABLE "token" ALTER COLUMN "access_token" TYPE VARCHAR(128) USING "access_token"::VARCHAR(128);""" diff --git a/api/asset_manager/src/mixins/CMDMixin.py b/api/asset_manager/src/mixins/CMDMixin.py new file mode 100644 index 00000000..1d3b1b11 --- /dev/null +++ b/api/asset_manager/src/mixins/CMDMixin.py @@ -0,0 +1,11 @@ +from tortoise import fields + +class CMDMixin(): + """ + Created, modified and delete mixin, these are required for every class. + """ + + 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) + diff --git a/api/asset_manager/src/__init__.py b/api/asset_manager/src/mixins/__init__.py similarity index 100% rename from api/asset_manager/src/__init__.py rename to api/asset_manager/src/mixins/__init__.py diff --git a/api/asset_manager/src/models.py b/api/asset_manager/src/models.py deleted file mode 100644 index 308a9120..00000000 --- a/api/asset_manager/src/models.py +++ /dev/null @@ -1,74 +0,0 @@ -from enum import Enum -from typing import Type -from tortoise.exceptions import ConfigurationError -from tortoise.models import Model -from tortoise import fields - -class EnumField(fields.CharField): - """ - An example extension to CharField that 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): - HOME = 1 # Home use (Any size) - SMALL_ORGANIZATION = 2 # 1-100 - MEDIUM_ORGANIZATION = 3 # 100 - 500 - LARGE_ORGANIZATION = 4 # 500 - 1000 - EXTRA_LARGE_ORGANIZATION = 5 # 1000 - 5000+ - -class CreatedAndModifiedMixin(): - created = fields.DatetimeField(null=True, auto_now_add=True) - modified = fields.DatetimeField(null=True, auto_now=True) - -class Organization(Model, CreatedAndModifiedMixin): - id = fields.UUIDField(primary_key=True) - name = fields.CharField(max_length=128) - type = EnumField(OrganizationType) - users = fields.ManyToManyField('models.User', - related_name='members', - through="Membership", - forward_key='user_id', - backward_key='organization_id', - null=True - ) - - def __str__(self) -> str: - return f"{self.id} - {self.name}" - -class User(Model, CreatedAndModifiedMixin): - id = fields.UUIDField(primary_key=True) - name = fields.TextField() - surname = fields.TextField() - password = fields.CharField(max_length=128, null=True) - organizations = fields.ManyToManyField('models.Organization', - related_name='members', - through='Membership', - forward_key='organization_id', - backward_key='user_id', - null=True - ) - - def __str__(self) -> str: - return f"{self.id} - {self.name} {self.surname}" - -class Membership(Model, CreatedAndModifiedMixin): - organization = fields.ForeignKeyField('models.Organization') - user = fields.ForeignKeyField('models.User') diff --git a/api/asset_manager/src/modules/assets/models.py b/api/asset_manager/src/modules/assets/models.py index 581014bf..7b3bf32d 100644 --- a/api/asset_manager/src/modules/assets/models.py +++ b/api/asset_manager/src/modules/assets/models.py @@ -1,6 +1,7 @@ from tortoise.models import Model from tortoise import fields +from mixins.CMDMixin import CMDMixin -class Asset(Model): +class Asset(Model, CMDMixin): id = fields.UUIDField(primary_key=True) name = fields.CharField(max_length=128) diff --git a/api/asset_manager/src/modules/assets/router.py b/api/asset_manager/src/modules/assets/router.py index fbe3f3f6..00cc9ab0 100644 --- a/api/asset_manager/src/modules/assets/router.py +++ b/api/asset_manager/src/modules/assets/router.py @@ -2,25 +2,22 @@ from uuid import UUID from fastapi.routing import APIRouter -from .models import Asset - router = APIRouter( prefix="/assets" ) @router.get("/") async def get_all_assets(): - return await Asset.get_or_none() + pass @router.post("/") async def create_asset(name: str): - asset = await Asset.create(name=name) - return asset + pass @router.delete("/", status_code=204) async def delete_asset(remove_id: UUID): - await Asset.filter(id=remove_id).delete() + pass @router.get("/{asset_id}") async def get_asset(asset_id: UUID): - return Asset.filter(id=asset_id).get_or_none() + pass diff --git a/api/asset_manager/src/modules/auth/models.py b/api/asset_manager/src/modules/auth/models.py index 8b137891..a1c3b0cb 100644 --- a/api/asset_manager/src/modules/auth/models.py +++ b/api/asset_manager/src/modules/auth/models.py @@ -1 +1,29 @@ +import pytz +from tortoise.models import Model +from tortoise import fields +import uuid +from datetime import datetime + +from mixins.CMDMixin import CMDMixin +from config import settings + + +class Token(Model, CMDMixin): + """ + Token + + Creates the access tokens for the User + """ + + id: uuid.UUID = fields.UUIDField(primary_key=True) + user: uuid.UUID = fields.ForeignKeyField("models.User") + token_type: str = fields.CharField(max_length=128, default="Bearer") + access_token: str = fields.TextField(null=True) + refresh_token: str = fields.TextField(null=True) + disabled: bool = fields.BooleanField(default=False) + + async def delete(self) -> None: + self.disabled = True + self.disabled_at = datetime.now(tz=pytz.UTC) + await self.save() diff --git a/api/asset_manager/src/modules/auth/router.py b/api/asset_manager/src/modules/auth/router.py index 8b137891..39cf1c08 100644 --- a/api/asset_manager/src/modules/auth/router.py +++ b/api/asset_manager/src/modules/auth/router.py @@ -1 +1,141 @@ +from datetime import datetime +from typing import Annotated +import uuid +from fastapi.security import OAuth2PasswordRequestForm +from fastapi.routing import APIRouter +import pytz +from modules.users.utils import get_current_active_user +from modules.auth.utils import create_jwt_tokens, get_tokens_from_logged_in_user +from modules.auth.models import Token +from modules.users.models import User +from fastapi import Depends, HTTPException, status +from tortoise.expressions import Q +from config import settings +from modules.users.schemas import user_model +from modules.auth.schemas import register_model +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +account_error: str = "E-Mail Address or password is incorrect" +token_error: str = "Refresh token not found or something went wrong." +user_exists: str = "Account failed to create, please contact support." +password_failed: str = "Password validation failed, please try again." + +crypt = settings.CRYPT + + +@router.post("/login") +async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): + """ + Login + + Logs the user into our API, creates tokens and passes them back to User. + """ + user: User | None = await User.filter(email=form.username).first() + + if user is None: + raise HTTPException(status_code=401, detail=account_error) + + if user.check_against_password(form.password) is False: + raise HTTPException(status_code=401, detail=account_error) + + if user.disabled is True: + raise HTTPException(status_code=401, detail=account_error) + + tokens = await create_jwt_tokens(user) + + return {"jwt": tokens} + + +@router.get("/logout", status_code=204) +async def logout(user: Annotated[User, Depends(get_current_active_user)]): + """ + Logout + + Logout destroys all tokens for User that are currently active. + """ + get_all_tokens = await Token.filter(Q(user__id=user.id)) + if get_all_tokens is None: + raise HTTPException( + status_code=status.HTTP_204_NO_CONTENT, detail="An error occurred." + ) + for token in get_all_tokens: + await token.delete() + return + + +@router.post("/refresh") +async def refresh_login( + refresh_token: Annotated[Token | None, Depends(get_tokens_from_logged_in_user)], +): + """ + Refresh + + After ging this route a token that is active and not disabled, we disable ALL other tokens and pass along new tokens. + Tokens are alive for about 10 minutes. Refresh tokens are alive for 20 minutes. + """ + if refresh_token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=token_error, + ) + + # Disable tokens if used after expiration. + if ( + refresh_token.created_at >= datetime.now(tz=pytz.utc) + and refresh_token.disabled is False + ): + refresh_token.delete() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=token_error, + ) + + if refresh_token.disabled is True: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=token_error, + ) + + get_all_tokens = await Token.filter(Q(user__id=refresh_token.user_id)) + + for token in get_all_tokens: + if token.id != refresh_token.id: + await token.delete() + + tokens = await create_jwt_tokens( + user=await User.filter(Q(id=refresh_token.user_id)).first() + ) + + return {"jwt": tokens} + + +@router.post("/register", status_code=201, response_model=user_model) +async def register(user: register_model): + # Prevent existing users from reapplying for our system. + existing_user: User | None = await User.filter( + Q(email=user.email) + & Q(username=user.username) + & Q(name=user.name) + & Q(surname=user.surname) + ).get_or_none() + + if existing_user is not None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=user_exists, + ) + + if user.password != user.validate_password: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=password_failed, + ) + + return await User.create( + email=user.email, + username=user.username, + name=user.name, + surname=user.surname, + password=crypt.hash(user.password), + ) diff --git a/api/asset_manager/src/modules/auth/schemas.py b/api/asset_manager/src/modules/auth/schemas.py new file mode 100644 index 00000000..08d312bc --- /dev/null +++ b/api/asset_manager/src/modules/auth/schemas.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, EmailStr +from tortoise.contrib.pydantic import pydantic_model_creator + +from modules.auth.models import Token + +token_model = pydantic_model_creator(Token) + +class register_model(BaseModel): + email: EmailStr + username: str + name: str + surname: str + password: str + validate_password: str \ No newline at end of file diff --git a/api/asset_manager/src/modules/auth/utils.py b/api/asset_manager/src/modules/auth/utils.py new file mode 100644 index 00000000..75ab6ac0 --- /dev/null +++ b/api/asset_manager/src/modules/auth/utils.py @@ -0,0 +1,82 @@ +from datetime import timedelta +from typing import Annotated +import uuid, time + +from tortoise.expressions import Q +from fastapi import Depends, HTTPException, status + +from modules.users.models import User +from modules.auth.models import Token +from config import settings +from joserfc import jwt # type: ignore +from joserfc.jwk import OctKey # type: ignore +from config import settings + +crypt = settings.CRYPT + + +def create_token(user_id: uuid, offset: timedelta) -> str: + """ + Creates a JWT token + """ + user = str(user_id) + curr_time = int(time.time()) + + return jwt.encode( + {"alg": "HS256", "typ": "JWT"}, + { + "iss": f"{settings.PROJECT_PUBLIC_URL}", + "sub": f"id:{user}", + "nbf": curr_time, + "iat": curr_time, + "exp": int(time.time() + offset.total_seconds()), + }, + OctKey.import_key(settings.SECRET_KEY), + ) + + +async def create_jwt_tokens(user: User) -> Token: + """ + Create a Token class with the following entities: + + 1) A user that is attached to the Token + 2) A fresh Auth Token + 3) A fresh Refresh Token. + + This is then returned in the form of an Token class. + """ + auth_token = create_token( + user_id=user.id, offset=timedelta(settings.ACCESS_TOKEN_EXPIRE_MIN) + ) + + refresh_token = create_token( + user_id=user.id, offset=timedelta(settings.REFRESH_TOKEN_EXPIRE_MIN) + ) + + return await Token.create( + user=user, + access_token=auth_token, + refresh_token=refresh_token, + ) + + +async def get_tokens_from_logged_in_user( + token: Annotated[str, Depends(settings.OAUTH2_SCHEME)] +) -> User | None: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="An issue occurred with the token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload: jwt.Token = jwt.decode( + token, OctKey.import_key(settings.SECRET_KEY), algorithms=["HS256"] + ) + id: str | None = payload.claims.get("sub", None) + if id is None: + raise credentials_exception + user_id = id.split(":")[1] + except: + raise credentials_exception + + return await Token.filter(Q(refresh_token=token) & Q(user__id=user_id)).first() diff --git a/api/asset_manager/src/modules/organizations/__init__.py b/api/asset_manager/src/modules/organizations/__init__.py new file mode 100644 index 00000000..e69de29b 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..b8e4bf70 --- /dev/null +++ b/api/asset_manager/src/modules/organizations/models.py @@ -0,0 +1,89 @@ +from datetime import datetime +from enum import Enum +from typing import Type +import uuid +import pytz +from tortoise.exceptions import ConfigurationError +from tortoise.models import Model +from tortoise import fields + +from mixins.CMDMixin import CMDMixin + +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, _) -> 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. + """ + + HOME: str = "home" # Home use (Any size) + NON_PROFIT: str = "non_profit" + SMALL_ORGANIZATION: str = "s_org" # 1-100 + MEDIUM_ORGANIZATION: str = "m_org" # 100 - 500 + LARGE_ORGANIZATION: str = "l_org" # 500 - 1000 + EXTRA_LARGE_ORGANIZATION: str = "xl_org" # 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.UUID = fields.UUIDField(primary_key=True) + name: str = fields.CharField(max_length=128) + type: str = EnumField(OrganizationType) + users: uuid.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}" + + async def delete(self, force: bool = False) -> None: + if force: + await Model.delete(self) + else: + self.disabled = True + self.disabled_at = datetime.now(tz=pytz.UTC) + await self.save() + + diff --git a/api/asset_manager/src/modules/organizations/router.py b/api/asset_manager/src/modules/organizations/router.py new file mode 100644 index 00000000..9cb14a5b --- /dev/null +++ b/api/asset_manager/src/modules/organizations/router.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + + +router = APIRouter(prefix="/api/v1/organizations") + +@router.get("/") +def all_organizations(): + pass + +@router.delete("/") +def delete_organization(): + pass + +@router.post("/create") +def create_organization(): + pass + diff --git a/api/asset_manager/src/modules/organizations/schemas.py b/api/asset_manager/src/modules/organizations/schemas.py new file mode 100644 index 00000000..272a700d --- /dev/null +++ b/api/asset_manager/src/modules/organizations/schemas.py @@ -0,0 +1,6 @@ +from tortoise.contrib.pydantic import pydantic_model_creator + +from modules.organizations.models import Organization + +OrganizationModel = pydantic_model_creator(Organization) + diff --git a/api/asset_manager/src/modules/users/__init__.py b/api/asset_manager/src/modules/users/__init__.py new file mode 100644 index 00000000..e69de29b 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..6afb9dd5 --- /dev/null +++ b/api/asset_manager/src/modules/users/models.py @@ -0,0 +1,112 @@ +from datetime import datetime +import uuid +from pydantic import EmailStr +import pytz +from tortoise.models import Model +from tortoise import fields + +from modules.organizations.models import Organization +from mixins.CMDMixin import CMDMixin +from config import settings + +crypt = settings.CRYPT + + +class User(Model, CMDMixin): + """ + User + + This holds all of our users + """ + + id: uuid.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}" + + async def set_password(self, password: str) -> None: + self.password = crypt.hash(password) + await self.save() # Make sure to save the model in DB + + def check_against_password(self, password: str) -> bool: + return crypt.verify(password, self.password) + + async 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 + await self.set_password(new_password) + + async def delete(self, force: bool = False) -> None: + if force: + await Model.delete(self) + else: + self.disabled = True + self.disabled_at = datetime.now(tz=pytz.UTC) + await 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.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.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) + + async def delete(self, force: bool = False) -> None: + if force: + await Model.delete(self) + else: + self.disabled = True + self.disabled_at = datetime.now(tz=pytz.UTC) + await 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..4418c8e8 --- /dev/null +++ b/api/asset_manager/src/modules/users/router.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter + +from modules.users.models import User + + +router = APIRouter(prefix="/api/v1/users", tags=["users"]) + + +@router.get("/") +def get_all_users(): + pass + +@router.post("/") +def create_user(): + pass + + +@router.get("/me") +def get_user(): + pass 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..07504195 --- /dev/null +++ b/api/asset_manager/src/modules/users/schemas.py @@ -0,0 +1,5 @@ +from tortoise.contrib.pydantic import pydantic_model_creator + +from modules.users.models import User + +user_model = pydantic_model_creator(User, exclude=["password"]) diff --git a/api/asset_manager/src/modules/users/utils.py b/api/asset_manager/src/modules/users/utils.py new file mode 100644 index 00000000..5af250df --- /dev/null +++ b/api/asset_manager/src/modules/users/utils.py @@ -0,0 +1,47 @@ +from typing import Annotated +from joserfc import jwt # type: ignore +from joserfc.jwk import OctKey # type: ignore + +from tortoise.expressions import Q +from fastapi import Depends, HTTPException, status + +from modules.users.models import User +from config import settings + + +async def get_user_from_token( + token: Annotated[str, Depends(settings.OAUTH2_SCHEME)] +) -> User | None: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="An issue occurred with the token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload: jwt.Token = jwt.decode( + token, OctKey.import_key(settings.SECRET_KEY), algorithms=["HS256"] + ) + id: str | None = payload.claims.get("sub", None) + if id is None: + raise credentials_exception + user_id = id.split(":")[1] + except: + raise credentials_exception + + return await User.filter(Q(id=user_id)).first() + + +async def get_current_active_user( + user: Annotated[User | None, Depends(get_user_from_token)], +) -> User: + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User is not found or active", + ) + if user.disabled: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User is not found or active", + ) + return user diff --git a/api/asset_manager/src/pyproject.toml b/api/asset_manager/src/pyproject.toml index dad15e41..b604f244 100644 --- a/api/asset_manager/src/pyproject.toml +++ b/api/asset_manager/src/pyproject.toml @@ -1,4 +1,18 @@ +[tool.black] +exclude = '''/ + # Default values for Black. + \.eggs|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist| +/''' +line-length = 88 + [tool.aerich] tortoise_orm = "database.TORTOISE_ORM" location = "./migrations" src_folder = "./." + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +testpaths = [ + "tests/", +] diff --git a/api/asset_manager/src/requirements/requirements.txt b/api/asset_manager/src/requirements/requirements.txt index 8e71abb8..4d6f979c 100644 --- a/api/asset_manager/src/requirements/requirements.txt +++ b/api/asset_manager/src/requirements/requirements.txt @@ -1,7 +1,21 @@ -aerich==0.8.0 -fastapi[standard]==0.115.5 -uvicorn==0.31.1 -tortoise-orm[asyncpg]==0.22.1 -black==24.10.0 -python-dotenv==1.0.1 -uvicorn[standard]==0.34.0 +aerich>=0.8.0 +fastapi[all]>=0.115.5 +python-dotenv>=0.21.0 +tortoise-orm[asyncpg]>=0.22.1 +uvicorn>=0.31.1 +black>=24.10.0 +joserfc>=1.0.1 +passlib>=1.7.4 +pytz>=2024.2 +ptpython>=0.25 +msgspec>=0.19.0 +bcrypt>=4.2.1 + +# Test Suite +httpx>=0.28.1 +pytest>=8.3.4 +mock>=5.1.0 +asyncio>=3.4.3 +pytest-mock>=3.14.0 +pytest-asyncio>=0.25.3 +asgi-lifespan>=2.1.0 \ No newline at end of file diff --git a/api/asset_manager/src/responses.py b/api/asset_manager/src/responses.py new file mode 100644 index 00000000..ef2fdf3c --- /dev/null +++ b/api/asset_manager/src/responses.py @@ -0,0 +1,16 @@ +from typing import Any +from fastapi.responses import JSONResponse + +try: + import msgspec # type: ignore +except ImportError: # pragma: nocover + msgspec = None # type: ignore + +class msgspec_jsonresponse(JSONResponse): + """ + JSON Response using the high-performance msgspec lib to serialize data to JSON. + """ + + def render(self, content: Any) -> bytes: + assert msgspec is not None, "msgspec must be installed to use msgspec_jsonresponse" + return msgspec.json.encode(content) diff --git a/api/asset_manager/src/router.py b/api/asset_manager/src/router.py new file mode 100644 index 00000000..ba514bd7 --- /dev/null +++ b/api/asset_manager/src/router.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse, RedirectResponse + + +router = APIRouter(prefix="/api/v1") + + +@router.get("/") +async def main() -> RedirectResponse: + return RedirectResponse(url="/docs") + + +@router.get("/ping") +async def ping() -> JSONResponse: + return {"ping": "pong!"} diff --git a/api/asset_manager/src/templates/email.html b/api/asset_manager/src/templates/email.html new file mode 100644 index 00000000..e69de29b diff --git a/api/asset_manager/src/tests/__init__.py b/api/asset_manager/src/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/asset_manager/src/tests/conftest.py b/api/asset_manager/src/tests/conftest.py new file mode 100644 index 00000000..8f2665c5 --- /dev/null +++ b/api/asset_manager/src/tests/conftest.py @@ -0,0 +1,54 @@ +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncGenerator +import httpx, pytest +from config import settings +from glob import glob + +from asgi_lifespan import LifespanManager # type: ignore + +settings.PSQL_DB_NAME = settings.PSQL_TEST_DB_NAME + +pytest_plugins = [ + fixture.replace("/", ".").replace("\\", ".").replace(".py", "") + for fixture in glob("tests/fixtures/*.py") + if "__" not in fixture +] + +try: + from main import app +except ImportError: + import sys + from pathlib import Path + + sys.path.append(str(Path(__file__).parent.parent)) + from main import app + +ClientManagerType = AsyncGenerator[httpx.AsyncClient, None] + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.get_event_loop() + yield loop + loop.close() + + +@asynccontextmanager +async def client_manager(app, base_url="https://localhost", **kw) -> ClientManagerType: + app.state.testing = True + async with LifespanManager(app): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url=base_url, **kw) as c: + yield c + + +@pytest.fixture(scope="module") +async def client() -> ClientManagerType: + async with client_manager(app) as c: + yield c diff --git a/api/asset_manager/src/tests/fixtures/__init__.py b/api/asset_manager/src/tests/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/asset_manager/src/tests/fixtures/account_fixtures.py b/api/asset_manager/src/tests/fixtures/account_fixtures.py new file mode 100644 index 00000000..ac49fac0 --- /dev/null +++ b/api/asset_manager/src/tests/fixtures/account_fixtures.py @@ -0,0 +1,86 @@ +from modules.organizations.models import Organization, OrganizationType +from modules.users.models import ACL, Membership, User +import pytest # type=ignore +from config import settings + +crypt = settings.CRYPT + + +@pytest.fixture() +async def use_user_account(): + org, _ = await Organization.get_or_create( + id="6ad4c94e-0522-4912-8d16-02d451f4c92d", + defaults={ + "name": "User's Organization", + "type": OrganizationType.HOME, + }, + ) + acl, _ = await ACL.get_or_create( + id="a4e927a3-36e5-4761-badb-0a44ade6616f", + defaults={ + "READ": True, + "WRITE": True, + "REPORT": True, + "MANAGE": False, + "ADMIN": False, + }, + ) + user, _ = await User.get_or_create( + id="24235427-9662-4ba3-a9c5-00000000000b", + defaults={ + "email": "user@localhost.com", + "username": "user", + "name": "awesome", + "surname": "user", + "password": crypt.hash("userpassword"), + }, + ) + membership, _ = await Membership.get_or_create( + id="833b9511-b2da-4760-8fa4-1a5c7059911e", + defaults={ + "organization": org, + "user": user, + "acl": acl, + }, + ) + return org, acl, user, membership + + +@pytest.fixture() +async def use_admin_account(): + org, _ = await Organization.get_or_create( + id="de001f44-1bb8-4667-9f9d-2d62d6ad7270", + defaults={ + "name": "Admin's Organization", + "type": OrganizationType.EXTRA_LARGE_ORGANIZATION, + }, + ) + acl, _ = await ACL.get_or_create( + id="83c1bfe6-c2ed-4ba1-be03-0e5c1960ec31", + defaults={ + "READ": True, + "WRITE": True, + "REPORT": True, + "MANAGE": True, + "ADMIN": True, + }, + ) + user, _ = await User.get_or_create( + id="24235427-9662-4ba3-a9c5-00000000000a", + defaults={ + "email": "admin@localhost.com", + "username": "admin", + "name": "awesome", + "surname": "admin", + "password": crypt.hash("adminpassword"), + }, + ) + membership, _ = await Membership.get_or_create( + id="393473ee-c218-4bcf-82cd-cb676c4d8a33", + defaults={ + "organization": org, + "user": user, + "acl": acl, + }, + ) + return org, acl, user, membership diff --git a/api/asset_manager/src/tests/test_accounts/__init__.py b/api/asset_manager/src/tests/test_accounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/asset_manager/src/tests/test_authentication/__init__.py b/api/asset_manager/src/tests/test_authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/asset_manager/src/tests/test_authentication/test_authentication.py b/api/asset_manager/src/tests/test_authentication/test_authentication.py new file mode 100644 index 00000000..5ed5092c --- /dev/null +++ b/api/asset_manager/src/tests/test_authentication/test_authentication.py @@ -0,0 +1,200 @@ +from modules.users.models import User +import pytest # type: ignore +from httpx import AsyncClient +from config import settings +from unittest.mock import ANY +from tortoise.expressions import Q + +crypt = settings.CRYPT + + +class TestAuthentication(object): + @pytest.mark.asyncio + async def test_authentication_with_non_existing_user_and_password( + self, client: AsyncClient + ): + response = await client.post( + "https://localhost/api/v1/auth/login", + data={ + "username": "non-existing@localhost.com", + "password": "password", + "grant_type": "password", + }, + ) + assert response.status_code == 401 + assert response.json() == {"detail": "E-Mail Address or password is incorrect"} + + @pytest.mark.asyncio + async def test_authentication_with_existing_user_and_wrong_password( + self, client: AsyncClient, use_admin_account + ): + _, _, _, _ = use_admin_account + response = await client.post( + "https://localhost/api/v1/auth/login", + data={ + "username": "admin@localhost.com", + "password": "password", + "grant_type": "password", + }, + ) + assert response.status_code == 401 + assert response.json() == {"detail": "E-Mail Address or password is incorrect"} + + @pytest.mark.asyncio + async def test_authentication_with_existing_user_and_password( + self, client: AsyncClient, use_admin_account + ): + _, _, admin, _ = use_admin_account + response = await client.post( + "https://localhost/api/v1/auth/login", + data={ + "username": "admin@localhost.com", + "password": "adminpassword", + "grant_type": "password", + }, + ) + assert response.status_code == 200 + assert response.json() == { + "jwt": { + "created_at": ANY, + "user_id": str(admin.id), + "id": ANY, + "modified_at": ANY, + "disabled_at": None, + "refresh_token": ANY, + "disabled": False, + "access_token": ANY, + "token_type": "Bearer", + } + } + + @pytest.mark.asyncio + async def test_logging_out_destroys_tokens( + self, client: AsyncClient, use_user_account + ): + _, _, user, _ = use_user_account + response = await client.post( + "https://localhost/api/v1/auth/login", + data={ + "username": "user@localhost.com", + "password": "userpassword", + "grant_type": "password", + }, + ) + assert response.status_code == 200 + assert response.json() == { + "jwt": { + "created_at": ANY, + "user_id": str(user.id), + "id": ANY, + "modified_at": ANY, + "disabled_at": None, + "refresh_token": ANY, + "disabled": False, + "access_token": ANY, + "token_type": "Bearer", + } + } + + access_token = response.json()["jwt"]["access_token"] + refresh_token = response.json()["jwt"]["refresh_token"] + + logout = await client.get( + "https://localhost/api/v1/auth/logout", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert logout.status_code == 204 + + refresh_request = await client.post( + "https://localhost/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {refresh_token}"}, + ) + + assert refresh_request.status_code == 401 + assert refresh_request.json() == { + "detail": "Refresh token not found or something went wrong." + } + + @pytest.mark.asyncio + async def test_create_new_tokens_upon_refresh( + self, client: AsyncClient, use_admin_account + ): + _, _, admin, _ = use_admin_account + token = await client.post( + "https://localhost/api/v1/auth/login", + data={ + "username": "admin@localhost.com", + "password": "adminpassword", + "grant_type": "password", + }, + ) + assert token.status_code == 200 + assert token.json() == { + "jwt": { + "created_at": ANY, + "user_id": str(admin.id), + "id": ANY, + "modified_at": ANY, + "disabled_at": None, + "refresh_token": ANY, + "disabled": False, + "access_token": ANY, + "token_type": "Bearer", + } + } + + refresh_token = token.json()["jwt"]["refresh_token"] + + response2 = await client.post( + "https://localhost/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {refresh_token}"}, + ) + + assert response2.status_code == 200 + assert response2.json() == { + "jwt": { + "created_at": ANY, + "user_id": str(admin.id), + "id": ANY, + "modified_at": ANY, + "disabled_at": None, + "refresh_token": ANY, + "disabled": False, + "access_token": ANY, + "token_type": "Bearer", + } + } + + @pytest.mark.asyncio + async def test_setup_new_account(self, client: AsyncClient): + # Ensure account is never available. Prevents account already being available. + check_if_account_exists: User | None = await User.filter( + Q(email="superuser@localhost.com") + ).get_or_none() + if check_if_account_exists: + await check_if_account_exists.delete(force=True) + + account = await client.post( + "https://localhost/api/v1/auth/register", + json={ + "email": "superuser@localhost.com", + "username": "superuser", + "name": "awesome", + "surname": "superuser", + "password": "superuserpassword", + "validate_password": "superuserpassword", + }, + ) + + assert account.status_code == 201 + assert account.json() == { + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "email": "superuser@localhost.com", + "id": ANY, + "modified_at": ANY, + "name": "awesome", + "surname": "superuser", + "username": "superuser", + } diff --git a/api/asset_manager/src/tests/test_general_routes/__init__.py b/api/asset_manager/src/tests/test_general_routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/asset_manager/src/tests/test_general_routes/test_main_routes.py b/api/asset_manager/src/tests/test_general_routes/test_main_routes.py new file mode 100644 index 00000000..f88e17d3 --- /dev/null +++ b/api/asset_manager/src/tests/test_general_routes/test_main_routes.py @@ -0,0 +1,13 @@ +import pytest # type: ignore +from httpx import AsyncClient + + +class TestRootRoute(object): + async def test_read_docs_on_main_route(self, client: AsyncClient): + response = await client.get("https://localhost/api/v1/") + assert response.status_code == 307 + + async def test_get_pong(self, client: AsyncClient): + response = await client.get("https://localhost/api/v1/ping") + assert response.status_code == 200 + assert response.json() == {"ping": "pong!"} diff --git a/docker-compose.yml b/docker-compose.yml index 6d4431d0..60ad9fe1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,12 @@ -version: "3.7" +volumes: + caddy_data: + caddy_config: + postgres: services: caddy: - image: caddy: + container_name: caddy + image: docker.io/caddy/caddy:2.9-alpine restart: unless-stopped ports: - "80:80" @@ -12,9 +16,15 @@ services: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/ping"] + interval: 5m + timeout: 5s + retries: 3 + start_period: 15s - fastapi: - container_name: "fastapi" + stoneedge: + container_name: "stoneedge" restart: unless-stopped build: context: ./api/asset_manager @@ -22,19 +32,21 @@ services: ports: - 8000:8000 healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: ["CMD", "curl", "-f", "http://localhost:8000/ping"] interval: 5m timeout: 5s retries: 3 start_period: 15s - links: - - "postgres" postgres: - container_name: "postgres" - image: postgres + container_name: postgres_container + image: docker.io/postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-passwd} + PGDATA: /data/postgres + volumes: + - postgres:/data/postgres + ports: + - "5432:5432" restart: unless-stopped - -volumes: - caddy_data: - caddy_config: diff --git a/web/README.md b/web/stoneedge/README.md similarity index 100% rename from web/README.md rename to web/stoneedge/README.md diff --git a/web/package-lock.json b/web/stoneedge/package-lock.json similarity index 100% rename from web/package-lock.json rename to web/stoneedge/package-lock.json diff --git a/web/package.json b/web/stoneedge/package.json similarity index 100% rename from web/package.json rename to web/stoneedge/package.json diff --git a/web/public/favicon.ico b/web/stoneedge/public/favicon.ico similarity index 100% rename from web/public/favicon.ico rename to web/stoneedge/public/favicon.ico diff --git a/web/public/index.html b/web/stoneedge/public/index.html similarity index 100% rename from web/public/index.html rename to web/stoneedge/public/index.html diff --git a/web/public/logo192.png b/web/stoneedge/public/logo192.png similarity index 100% rename from web/public/logo192.png rename to web/stoneedge/public/logo192.png diff --git a/web/public/logo512.png b/web/stoneedge/public/logo512.png similarity index 100% rename from web/public/logo512.png rename to web/stoneedge/public/logo512.png diff --git a/web/public/manifest.json b/web/stoneedge/public/manifest.json similarity index 100% rename from web/public/manifest.json rename to web/stoneedge/public/manifest.json diff --git a/web/public/robots.txt b/web/stoneedge/public/robots.txt similarity index 100% rename from web/public/robots.txt rename to web/stoneedge/public/robots.txt diff --git a/web/src/App.css b/web/stoneedge/src/App.css similarity index 100% rename from web/src/App.css rename to web/stoneedge/src/App.css diff --git a/web/src/App.test.tsx b/web/stoneedge/src/App.test.tsx similarity index 100% rename from web/src/App.test.tsx rename to web/stoneedge/src/App.test.tsx diff --git a/web/src/App.tsx b/web/stoneedge/src/App.tsx similarity index 100% rename from web/src/App.tsx rename to web/stoneedge/src/App.tsx diff --git a/web/src/index.css b/web/stoneedge/src/index.css similarity index 100% rename from web/src/index.css rename to web/stoneedge/src/index.css diff --git a/web/src/index.tsx b/web/stoneedge/src/index.tsx similarity index 100% rename from web/src/index.tsx rename to web/stoneedge/src/index.tsx diff --git a/web/src/logo.svg b/web/stoneedge/src/logo.svg similarity index 100% rename from web/src/logo.svg rename to web/stoneedge/src/logo.svg diff --git a/web/src/react-app-env.d.ts b/web/stoneedge/src/react-app-env.d.ts similarity index 100% rename from web/src/react-app-env.d.ts rename to web/stoneedge/src/react-app-env.d.ts diff --git a/web/src/reportWebVitals.ts b/web/stoneedge/src/reportWebVitals.ts similarity index 100% rename from web/src/reportWebVitals.ts rename to web/stoneedge/src/reportWebVitals.ts diff --git a/web/src/setupTests.ts b/web/stoneedge/src/setupTests.ts similarity index 100% rename from web/src/setupTests.ts rename to web/stoneedge/src/setupTests.ts diff --git a/web/tsconfig.json b/web/stoneedge/tsconfig.json similarity index 100% rename from web/tsconfig.json rename to web/stoneedge/tsconfig.json diff --git a/web/website/README.md b/web/website/README.md new file mode 100644 index 00000000..e69de29b