From d3a0348e1f1b627e3c14c8a56f3d2762b0c0fa91 Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Mon, 1 Jun 2026 15:40:55 +0300 Subject: [PATCH 01/12] feat: implement distributed locking for improved resource handling --- Makefile | 4 ++ docker-compose.dev.yml | 1 - docker-compose.example.yml | 1 - docker/entrypoint-dev.sh | 21 ++++++-- docker/entrypoint.sh | 35 +++++++++++-- fastid/api/factory.py | 18 +------ fastid/cache/exceptions.py | 4 ++ fastid/cache/locks.py | 84 ++++++++++++++++++++++++++++++++ fastid/cache/storage.py | 11 ++++- fastid/core/app.py | 21 +++++++- fastid/{api => core}/lifespan.py | 9 +++- fastid/core/workers.py | 2 +- fastid/database/config.py | 17 +++++++ fastid/database/dependencies.py | 21 +++++++- fastid/webhooks/use_cases.py | 19 ++++++-- tests/api/conftest.py | 2 +- 16 files changed, 230 insertions(+), 40 deletions(-) create mode 100644 fastid/cache/locks.py rename fastid/{api => core}/lifespan.py (90%) diff --git a/Makefile b/Makefile index 46f22ec..5b3c261 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,10 @@ deps: up: docker compose -f docker-compose.dev.yml up --build --remove-orphans --wait +.PHONY: build +build: + docker build -t fastid:latest -f docker/Dockerfile . + .PHONY: up-obs up-obs: docker compose -f docker-compose.dev.yml -f docker-compose.observability.yml up --build --remove-orphans --wait diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0a87a9f..22f0db8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -36,7 +36,6 @@ services: ports: - "8025:8025" - "1025:1025" - restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8025/readyz"] interval: 10s diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 52296a2..845333e 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -29,7 +29,6 @@ services: mailpit: image: axllent/mailpit:v1.30.1 - restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8025/readyz"] interval: 10s diff --git a/docker/entrypoint-dev.sh b/docker/entrypoint-dev.sh index 4e24922..9230f0a 100644 --- a/docker/entrypoint-dev.sh +++ b/docker/entrypoint-dev.sh @@ -4,6 +4,21 @@ set -e poetry run alembic upgrade head -# You can put other setup logic here -# Evaluating passed command: -exec poetry run uvicorn "fastid.core.app:core_app" --host 0.0.0.0 --port 8000 --reload "$@" +APP=${FASTID_UVICORN_APP:-fastid.core.app:core_app} +HOST=${FASTID_UVICORN_HOST:-0.0.0.0} +PORT=${FASTID_UVICORN_PORT:-8000} +RELOAD=${FASTID_UVICORN_RELOAD:-1} + +echo "Starting Uvicorn with the following configuration:" +echo " App: $APP" +echo " Host: $HOST" +echo " Port: $PORT" +echo " Reload: $RELOAD" + +if [ "$RELOAD" -eq 1 ]; then + RELOAD="--reload" +else + RELOAD="" +fi + +exec poetry run uvicorn "$APP" --host "$HOST" --port "$PORT" $RELOAD "$@" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index abe0ee9..fd4ec35 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -4,14 +4,39 @@ set -e alembic upgrade head -if [ -z "$WORKERS" ]; then +if [ -z "$FASTID_GUNICORN_WORKERS" ]; then CPU_COUNT=$(nproc --all 2>/dev/null || grep -c ^processor /proc/cpuinfo 2>/dev/null || echo 1) - # Например, 2 воркера на ядро + 1, но не меньше 1 WORKERS=$((CPU_COUNT * 2 + 1)) - # Ограничим максимум разумным значением, чтобы не перегружать БД [ "$WORKERS" -gt 16 ] && WORKERS=16 +else + WORKERS=$FASTID_GUNICORN_WORKERS fi -echo "Using $WORKERS workers" +APP=${FASTID_GUNICORN_APP:-fastid.core.app:core_app} +WORKER_CLASS=${FASTID_GUNICORN_WORKER_CLASS:-fastid.core.workers.MyUvicornWorker} +BIND=${FASTID_GUNICORN_BIND:-0.0.0.0:8000} +BACKLOG=${FASTID_GUNICORN_BACKLOG:-2048} +TIMEOUT=${FASTID_GUNICORN_TIMEOUT:-30} +GRACEFUL_TIMEOUT=${FASTID_GUNICORN_GRACEFUL_TIMEOUT:-10} +KEEP_ALIVE=${FASTID_GUNICORN_KEEP_ALIVE:-5} -exec gunicorn -w "$WORKERS" -k fastid.core.workers.MyUvicornWorker "fastid.core.app:core_app" -b 0.0.0.0:8000 "$@" +echo "Starting Gunicorn with the following configuration:" +echo " Workers: $WORKERS" +echo " Worker Class: $WORKER_CLASS" +echo " App: $APP" +echo " Bind: $BIND" +echo " Backlog: $BACKLOG" +echo " Timeout: $TIMEOUT" +echo " Graceful Timeout: $GRACEFUL_TIMEOUT" +echo " Keep Alive: $KEEP_ALIVE" + +exec gunicorn \ + -w "$WORKERS" \ + -k fastid.core.workers.MyUvicornWorker \ + "$APP" \ + -b "$BIND" \ + --backlog "$BACKLOG" \ + --timeout "$TIMEOUT" \ + --graceful-timeout "$GRACEFUL_TIMEOUT" \ + --keep-alive "$KEEP_ALIVE" \ + "$@" diff --git a/fastid/api/factory.py b/fastid/api/factory.py index 3f15a22..81e6e8b 100644 --- a/fastid/api/factory.py +++ b/fastid/api/factory.py @@ -1,17 +1,13 @@ -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager +from collections.abc import Sequence from typing import Any from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastid.api.exceptions import add_exception_handlers -from fastid.api.lifespan import LifespanTasks from fastid.api.routing import api_router -from fastid.cache.dependencies import get_cache from fastid.core.base import AppFactory, Plugin from fastid.core.dependencies import log -from fastid.database.dependencies import get_uow_raw from fastid.webhooks.router import router as webhooks_router @@ -37,22 +33,10 @@ def __init__( # noqa: PLR0913 self.fastapi_kwargs = fastapi_kwargs def create(self) -> FastAPI: - @asynccontextmanager - async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - # Startup tasks - tasks = LifespanTasks(uow_factory=get_uow_raw, cache_factory=get_cache) - async with tasks: - await tasks.on_startup() - yield - # Shutdown tasks - async with tasks: - await tasks.on_shutdown() - app = FastAPI( title=self.title, version=self.version, webhooks=webhooks_router, - lifespan=lifespan, root_path=self.base_url, **self.fastapi_kwargs, ) diff --git a/fastid/cache/exceptions.py b/fastid/cache/exceptions.py index 837f497..740ac2f 100644 --- a/fastid/cache/exceptions.py +++ b/fastid/cache/exceptions.py @@ -4,3 +4,7 @@ class CacheError(Exception): class KeyNotFoundError(CacheError): pass + + +class LockError(CacheError): + pass diff --git a/fastid/cache/locks.py b/fastid/cache/locks.py new file mode 100644 index 0000000..f9f6a6e --- /dev/null +++ b/fastid/cache/locks.py @@ -0,0 +1,84 @@ +import asyncio +import uuid +from types import TracebackType +from typing import Self + +from redis.asyncio import Redis + +from fastid.cache.exceptions import LockError + +UNLOCK_SCRIPT = """ + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + """ + + +class DistributedLock: + def __init__( + self, + redis: Redis, + name: str, + *, + lock_timeout: int = 30, + blocking: bool = False, + blocking_timeout: float | None = None, + ) -> None: + self._redis = redis + self._name = f"lock:{name}" + self._lock_timeout = lock_timeout + self._blocking = blocking + self._blocking_timeout = blocking_timeout + self._token: str = "" + self._unlock_script = self._redis.register_script(UNLOCK_SCRIPT) + + async def acquire(self) -> bool: + self._token = str(uuid.uuid4()) + if self._blocking: + deadline = None + if self._blocking_timeout is not None: + deadline = asyncio.get_event_loop().time() + self._blocking_timeout + while True: + acquired = await self._redis.set( + self._name, + self._token, + nx=True, + ex=self._lock_timeout, + ) + if acquired: + return True + if deadline and asyncio.get_event_loop().time() >= deadline: + return False + await asyncio.sleep(0.1) + else: + return bool( + await self._redis.set( + self._name, + self._token, + nx=True, + ex=self._lock_timeout, + ) + ) + + async def release(self) -> None: + if not self._token: + return + await self._unlock_script(keys=[self._name], args=[self._token]) + self._token = "" + + async def __aenter__(self) -> Self: + if not await self.acquire(): + msg = f"Could not acquire lock '{self._name}'" + raise LockError(msg) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + await self.release() + return False diff --git a/fastid/cache/storage.py b/fastid/cache/storage.py index 7642075..39329bd 100644 --- a/fastid/cache/storage.py +++ b/fastid/cache/storage.py @@ -5,6 +5,7 @@ from redis.asyncio import Redis from fastid.cache.exceptions import KeyNotFoundError +from fastid.cache.locks import DistributedLock class CacheStorage(ABC): @@ -28,6 +29,9 @@ async def pop(self, key: str) -> str: ... @abstractmethod async def healthcheck(self) -> None: ... + @abstractmethod + def lock(self, name: str, **kwargs: Any) -> DistributedLock: ... + class RedisStorage(CacheStorage): key: str @@ -46,12 +50,12 @@ async def set(self, key: str, value: Any, *, expire: int | None = None) -> str: await self.client.set(f"{self.key}:{key}", json_str, ex=expire) return json_str - async def get(self, key: str) -> str: + async def get(self, key: str) -> Any: value = await self.client.get(f"{self.key}:{key}") if value is None: msg = f"Key {key} not found" raise KeyNotFoundError(msg) - return str(json.loads(value)) + return json.loads(value) async def delete(self, key: str) -> None: await self.client.delete(f"{self.key}:{key}") @@ -65,3 +69,6 @@ async def pop(self, key: str) -> str: async def healthcheck(self) -> None: await self.client.ping() + + def lock(self, name: str, **kwargs: Any) -> DistributedLock: + return DistributedLock(self.client, name, **kwargs) diff --git a/fastid/core/app.py b/fastid/core/app.py index 7d93e8d..969b160 100644 --- a/fastid/core/app.py +++ b/fastid/core/app.py @@ -1,11 +1,30 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastid.admin.app import admin_app from fastid.api.app import api_app +from fastid.cache.dependencies import get_cache from fastid.core.config import main_settings +from fastid.core.lifespan import LifespanTasks +from fastid.database.dependencies import get_uow_raw from fastid.frontend.app import frontend_app -core_app = FastAPI() + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + # Startup tasks + tasks = LifespanTasks(uow_factory=get_uow_raw, cache_factory=get_cache) + async with tasks: + await tasks.on_startup() + yield + # Shutdown tasks + async with tasks: + await tasks.on_shutdown() + + +core_app = FastAPI(lifespan=lifespan) core_app.mount(main_settings.api_path, api_app) core_app.mount(main_settings.admin_path, admin_app) diff --git a/fastid/api/lifespan.py b/fastid/core/lifespan.py similarity index 90% rename from fastid/api/lifespan.py rename to fastid/core/lifespan.py index becf535..f7d78db 100644 --- a/fastid/api/lifespan.py +++ b/fastid/core/lifespan.py @@ -5,6 +5,7 @@ from fastid.admin.config import admin_settings from fastid.auth.models import User from fastid.auth.repositories import EmailUserSpecification +from fastid.cache.exceptions import LockError from fastid.cache.storage import CacheStorage from fastid.database.exceptions import NoResultFoundError from fastid.database.uow import SQLAlchemyUOW @@ -19,8 +20,12 @@ def __init__(self, *, uow_factory: Callable[[], SQLAlchemyUOW], cache_factory: C async def on_startup(self) -> None: await self.healthcheck() - await self.create_admin() - await self.create_templates() + try: + async with self.cache.lock("seed", blocking=True): + await self.create_admin() + await self.create_templates() + except LockError: + pass async def healthcheck(self) -> None: await self.uow.healthcheck() diff --git a/fastid/core/workers.py b/fastid/core/workers.py index 161201f..ff8784d 100644 --- a/fastid/core/workers.py +++ b/fastid/core/workers.py @@ -2,4 +2,4 @@ class MyUvicornWorker(UvicornWorker): # type: ignore[misc] # pragma: nocover - CONFIG_KWARGS = {"proxy_headers": True, "forwarded_allow_ips": "*"} + CONFIG_KWARGS = {"loop": "uvloop", "http": "httptools", "proxy_headers": True, "forwarded_allow_ips": "*"} diff --git a/fastid/database/config.py b/fastid/database/config.py index f2d116b..53efd94 100644 --- a/fastid/database/config.py +++ b/fastid/database/config.py @@ -1,3 +1,6 @@ +from typing import Any + +from pydantic import Field, PositiveInt from pydantic_settings import SettingsConfigDict from fastid.core.schemas import BaseSettings @@ -7,6 +10,20 @@ class DBSettings(BaseSettings): url: str echo: bool = False + # --- pool settings --- + pool_size: PositiveInt = Field(default=5) + max_overflow: int = Field(default=10, ge=0) + pool_recycle: int = Field(default=3600, ge=0) + pool_timeout: int = Field(default=30, ge=0) + pool_pre_ping: bool = Field(default=True) + + # --- connect_args (asyncpg) --- + server_settings: dict[str, Any] = Field(default_factory=lambda: {"application_name": "fastid"}) + + # --- asyncpg timeouts --- + connect_timeout: PositiveInt | None = Field(default=10) + command_timeout: PositiveInt | None = Field(default=30) + model_config = SettingsConfigDict(env_prefix="db_") diff --git a/fastid/database/dependencies.py b/fastid/database/dependencies.py index 0a89976..7d7bc40 100644 --- a/fastid/database/dependencies.py +++ b/fastid/database/dependencies.py @@ -6,16 +6,33 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from fastid.database.config import db_settings +from fastid.database.config import DBSettings, db_settings from fastid.database.uow import SQLAlchemyUOW if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Coroutine + +def build_connect_args(settings: DBSettings) -> dict[str, Any]: + args: dict[str, Any] = { + "server_settings": settings.server_settings, + } + if settings.connect_timeout is not None: + args["timeout"] = settings.connect_timeout + if settings.command_timeout is not None: + args["command_timeout"] = settings.command_timeout + return args + + engine = create_async_engine( db_settings.url, echo=db_settings.echo, - pool_pre_ping=True, + pool_size=db_settings.pool_size, + max_overflow=db_settings.max_overflow, + pool_recycle=db_settings.pool_recycle, + pool_timeout=db_settings.pool_timeout, + pool_pre_ping=db_settings.pool_pre_ping, + connect_args=build_connect_args(db_settings), ) session_factory = async_sessionmaker(engine, expire_on_commit=False) diff --git a/fastid/webhooks/use_cases.py b/fastid/webhooks/use_cases.py index c14d3fa..44e9d54 100644 --- a/fastid/webhooks/use_cases.py +++ b/fastid/webhooks/use_cases.py @@ -1,3 +1,5 @@ +from fastid.cache.dependencies import CacheDep +from fastid.cache.exceptions import KeyNotFoundError from fastid.core.base import UseCase from fastid.database.dependencies import UOWRawDep, transactional from fastid.security.webhooks import ( @@ -6,25 +8,34 @@ get_timestamp, get_webhook_id, ) -from fastid.webhooks.models import WebhookEvent +from fastid.webhooks.models import Webhook, WebhookEvent from fastid.webhooks.repositories import WebhookTypeSpecification from fastid.webhooks.schemas import Event, SendWebhookRequest, WebhookPayload from fastid.webhooks.senders.dependencies import SenderDep class WebhookUseCases(UseCase): - def __init__(self, uow: UOWRawDep, sender: SenderDep) -> None: + def __init__(self, uow: UOWRawDep, cache: CacheDep, sender: SenderDep) -> None: self.uow = uow # Due to background nature of notification use cases, use raw dependency + self.cache = cache self.sender = sender @transactional async def send(self, dto: SendWebhookRequest) -> None: - webhooks = await self.uow.webhooks.get_many(WebhookTypeSpecification(dto.type)) + cache_key = f"webhooks:{dto.type}" + try: + cached = await self.cache.get(cache_key) + except KeyNotFoundError: + webhook_page = await self.uow.webhooks.get_many(WebhookTypeSpecification(dto.type)) + webhooks = webhook_page.items + await self.cache.set(cache_key, [w.dump() for w in webhooks], expire=60) + else: + webhooks = [Webhook(**w) for w in cached] event_id = get_event_id() event_timestamp = get_timestamp() event_dto = Event(event_type=dto.type, event_id=event_id, timestamp=event_timestamp) payload = WebhookPayload(event=event_dto, data=dto.payload).model_dump(mode="json") - for webhook in webhooks.items: + for webhook in webhooks: webhook_id = get_webhook_id() timestamp = get_timestamp() headers = generate_headers(payload, timestamp, str(webhook_id), webhook.secret) diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 0e8f6e6..ca1c611 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -7,11 +7,11 @@ from sqlalchemy.ext.asyncio import AsyncEngine from starlette import status -from fastid.api.lifespan import LifespanTasks from fastid.apps.schemas import AppDTO from fastid.auth.schemas import UserDTO from fastid.cache.storage import CacheStorage from fastid.core.dependencies import log_provider +from fastid.core.lifespan import LifespanTasks from fastid.database.uow import SQLAlchemyUOW from fastid.notify.schemas import UserAction from fastid.security.crypto import generate_otp From 52fa86e6da49717d5f2533154c30d478868f947c Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Fri, 5 Jun 2026 21:03:57 +0300 Subject: [PATCH 02/12] feat: remove fastlink internal using --- docker-compose.dev.yml | 10 +- docker-compose.example.yml | 16 +- docker/entrypoint.sh | 2 +- fastid/admin/app.py | 9 +- fastid/admin/config.py | 11 +- fastid/api/app.py | 32 +- fastid/api/config.py | 15 + fastid/{core/middlewares => api}/cors.py | 0 fastid/auth/config.py | 22 +- fastid/auth/dependencies.py | 2 +- fastid/auth/grants.py | 100 +++-- fastid/auth/models.py | 16 +- fastid/auth/router.py | 29 +- fastid/auth/schemas.py | 344 ++++++++++++++++-- fastid/auth/utils.py | 2 +- fastid/cache/config.py | 15 +- fastid/cache/dependencies.py | 6 +- fastid/cache/storage.py | 2 + fastid/core/app.py | 8 +- fastid/core/config.py | 41 ++- fastid/core/dependencies.py | 4 +- fastid/core/schemas.py | 10 +- fastid/core/timer.py | 29 ++ fastid/database/config.py | 4 +- .../{core/middlewares => email}/__init__.py | 0 .../clients/smtp.py => email/client.py} | 0 fastid/email/config.py | 22 ++ fastid/email/dependencies.py | 28 ++ fastid/frontend/app.py | 4 +- fastid/frontend/dependencies.py | 4 +- fastid/frontend/factory.py | 7 +- fastid/frontend/openid.py | 4 +- fastid/frontend/router.py | 3 +- .../clients => integrations}/__init__.py | 0 .../clients => integrations/base}/__init__.py | 0 fastid/integrations/base/oauth.py | 198 ++++++++++ fastid/integrations/config.py | 31 ++ fastid/integrations/constants.py | 3 + fastid/integrations/dependencies.py | 67 ++++ fastid/integrations/exceptions.py | 42 +++ .../obs => integrations/google}/__init__.py | 0 fastid/integrations/google/oauth.py | 27 ++ fastid/integrations/registries.py | 26 ++ fastid/integrations/schemas.py | 30 ++ fastid/integrations/telegram/__init__.py | 0 fastid/integrations/telegram/login.py | 128 +++++++ .../telegram/notifications.py} | 0 fastid/integrations/utils.py | 39 ++ fastid/integrations/yandex/__init__.py | 0 fastid/integrations/yandex/oauth.py | 34 ++ fastid/notify/clients/dependencies.py | 41 --- fastid/notify/config.py | 33 +- fastid/notify/models.py | 5 +- fastid/notify/schemas.py | 5 +- fastid/notify/templating.py | 6 + fastid/notify/use_cases.py | 18 +- fastid/notify/utils.py | 4 +- fastid/oauth/clients/dependencies.py | 70 ---- fastid/oauth/clients/registry.py | 53 --- fastid/oauth/config.py | 46 --- fastid/oauth/metadata.py | 37 ++ fastid/oauth/router.py | 13 +- fastid/oauth/schemas.py | 10 +- fastid/oauth/use_cases.py | 52 +-- fastid/plugins/obs/config.py | 13 - fastid/plugins/observability/__init__.py | 0 fastid/plugins/observability/config.py | 10 + .../plugins/{obs => observability}/metrics.py | 2 +- .../plugins/{obs => observability}/panels.py | 0 .../{obs => observability}/prometheus.py | 2 +- .../plugins/{obs => observability}/tracing.py | 0 fastid/security/jwt.py | 10 +- fastid/security/transport.py | 110 ++++++ fastid/webhooks/config.py | 5 +- fastid/webhooks/models.py | 5 +- fastid/webhooks/schemas.py | 6 +- fastid/webhooks/use_cases.py | 7 +- migrations/env.py | 4 +- poetry.lock | 21 +- pyproject.toml | 1 - tests/api/apps/test_create_app.py | 2 +- tests/api/apps/test_delete_app.py | 2 +- tests/api/apps/test_get_app.py | 2 +- tests/api/apps/test_update_app.py | 2 +- ...test_authorize_authorization_code_grant.py | 2 +- .../test_authorize_refresh_token_grant.py | 2 +- .../test_callback_authorization_code_grant.py | 2 +- tests/api/auth/test_logout.py | 2 +- tests/api/auth/test_openid_configuration.py | 3 +- tests/api/auth/test_userinfo.py | 3 +- tests/api/conftest.py | 3 +- tests/api/notify/test_notify_otp.py | 3 +- tests/api/notify/test_send_email.py | 3 +- tests/api/notify/test_send_telegram.py | 2 +- tests/api/notify/test_verify_otp.py | 3 +- tests/api/oauth/test_oauth_callback.py | 40 +- tests/api/oauth/test_oauth_get_many.py | 3 +- tests/api/oauth/test_oauth_inspect.py | 2 +- tests/api/oauth/test_oauth_login.py | 3 +- tests/api/oauth/test_oauth_revoke.py | 3 +- tests/api/oauth/test_telegram_redirect.py | 4 +- tests/api/profile/test_delete_user.py | 3 +- tests/api/profile/test_update_user_email.py | 3 +- .../api/profile/test_update_user_password.py | 3 +- tests/api/profile/test_update_user_profile.py | 3 +- tests/api/superuser/test_delete_user.py | 3 +- tests/api/superuser/test_get_user.py | 3 +- tests/api/superuser/test_get_users.py | 3 +- tests/api/superuser/test_grant_su.py | 3 +- tests/api/superuser/test_revoke_su.py | 3 +- tests/api/superuser/test_update_user.py | 3 +- tests/conftest.py | 9 +- tests/dependencies.py | 6 +- tests/mocks.py | 58 +-- tests/utils/auth.py | 3 +- 115 files changed, 1603 insertions(+), 609 deletions(-) create mode 100644 fastid/api/config.py rename fastid/{core/middlewares => api}/cors.py (100%) create mode 100644 fastid/core/timer.py rename fastid/{core/middlewares => email}/__init__.py (100%) rename fastid/{notify/clients/smtp.py => email/client.py} (100%) create mode 100644 fastid/email/config.py create mode 100644 fastid/email/dependencies.py rename fastid/{notify/clients => integrations}/__init__.py (100%) rename fastid/{oauth/clients => integrations/base}/__init__.py (100%) create mode 100644 fastid/integrations/base/oauth.py create mode 100644 fastid/integrations/config.py create mode 100644 fastid/integrations/constants.py create mode 100644 fastid/integrations/dependencies.py create mode 100644 fastid/integrations/exceptions.py rename fastid/{plugins/obs => integrations/google}/__init__.py (100%) create mode 100644 fastid/integrations/google/oauth.py create mode 100644 fastid/integrations/registries.py create mode 100644 fastid/integrations/schemas.py create mode 100644 fastid/integrations/telegram/__init__.py create mode 100644 fastid/integrations/telegram/login.py rename fastid/{notify/clients/telegram.py => integrations/telegram/notifications.py} (100%) create mode 100644 fastid/integrations/utils.py create mode 100644 fastid/integrations/yandex/__init__.py create mode 100644 fastid/integrations/yandex/oauth.py delete mode 100644 fastid/notify/clients/dependencies.py create mode 100644 fastid/notify/templating.py delete mode 100644 fastid/oauth/clients/dependencies.py delete mode 100644 fastid/oauth/clients/registry.py delete mode 100644 fastid/oauth/config.py create mode 100644 fastid/oauth/metadata.py delete mode 100644 fastid/plugins/obs/config.py create mode 100644 fastid/plugins/observability/__init__.py create mode 100644 fastid/plugins/observability/config.py rename fastid/plugins/{obs => observability}/metrics.py (94%) rename fastid/plugins/{obs => observability}/panels.py (100%) rename fastid/plugins/{obs => observability}/prometheus.py (98%) rename fastid/plugins/{obs => observability}/tracing.py (100%) create mode 100644 fastid/security/transport.py diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 22f0db8..8cc7dd1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -60,11 +60,11 @@ services: condition: service_healthy environment: - DB_URL: ${DB_URL:-postgresql+asyncpg://${POSTGRES_USER:-fastid}:${POSTGRES_PASSWORD:?database password required}@postgres:5432/${POSTGRES_DB:-fastid}} - REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:?redis password required}@redis:6379/0} - NOTIFY_SMTP_ENABLED: ${NOTIFY_SMTP_ENABLED:-true} - NOTIFY_SMTP_HOST: ${NOTIFY_SMTP_HOST:-mailpit} - NOTIFY_SMTP_PORT: ${NOTIFY_SMTP_PORT:-1025} + FASTID_DB_URL: ${FASTID_DB_URL:-postgresql+asyncpg://${POSTGRES_USER:-fastid}:${POSTGRES_PASSWORD:?database password required}@postgres:5432/${POSTGRES_DB:-fastid}} + FASTID_REDIS_URL: ${FASTID_REDIS_URL:-redis://:${REDIS_PASSWORD:?redis password required}@redis:6379/0} + FASTID_SMTP_ENABLED: ${FASTID_SMTP_ENABLED:-true} + FASTID_SMTP_HOST: ${FASTID_SMTP_HOST:-mailpit} + FASTID_SMTP_PORT: ${FASTID_SMTP_PORT:-1025} volumes: - "./migrations:/opt/fastid/migrations" diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 845333e..f59ffe3 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -10,7 +10,7 @@ services: volumes: - pg_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fastid} -d ${POSTGRES_DB:-fastid}"] + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fastid} -d ${POSTGRES_DB:-fastid}" ] interval: 10s retries: 5 start_period: 30s @@ -22,7 +22,7 @@ services: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD:?redis password required} healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 5 @@ -30,7 +30,7 @@ services: mailpit: image: axllent/mailpit:v1.30.1 healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8025/readyz"] + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8025/readyz" ] interval: 10s timeout: 5s retries: 5 @@ -51,11 +51,11 @@ services: condition: service_healthy environment: - DB_URL: ${DB_URL:-postgresql+asyncpg://${POSTGRES_USER:-fastid}:${POSTGRES_PASSWORD:?database password required}@postgres:5432/${POSTGRES_DB:-fastid}} - REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:?redis password required}@redis:6379/0} - NOTIFY_SMTP_ENABLED: ${NOTIFY_SMTP_ENABLED:-true} - NOTIFY_SMTP_HOST: ${NOTIFY_SMTP_HOST:-mailpit} - NOTIFY_SMTP_PORT: ${NOTIFY_SMTP_PORT:-1025} + FASTID_DB_URL: ${FASTID_DB_URL:-postgresql+asyncpg://${POSTGRES_USER:-fastid}:${POSTGRES_PASSWORD:?database password required}@postgres:5432/${POSTGRES_DB:-fastid}} + FASTID_REDIS_URL: ${FASTID_REDIS_URL:-redis://:${REDIS_PASSWORD:?redis password required}@redis:6379/0} + FASTID_SMTP_ENABLED: ${FASTID_SMTP_ENABLED:-true} + FASTID_SMTP_HOST: ${FASTID_SMTP_HOST:-mailpit} + FASTID_SMTP_PORT: ${FASTID_SMTP_PORT:-1025} volumes: - "./certs:/opt/fastid/certs" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fd4ec35..feb01b1 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -32,7 +32,7 @@ echo " Keep Alive: $KEEP_ALIVE" exec gunicorn \ -w "$WORKERS" \ - -k fastid.core.workers.MyUvicornWorker \ + -k "$WORKER_CLASS" \ "$APP" \ -b "$BIND" \ --backlog "$BACKLOG" \ diff --git a/fastid/admin/app.py b/fastid/admin/app.py index bbb1302..a0803d2 100644 --- a/fastid/admin/app.py +++ b/fastid/admin/app.py @@ -1,12 +1,11 @@ -from fastid.admin.config import admin_settings from fastid.admin.factory import AdminAppFactory -from fastid.core.config import main_settings +from fastid.core.config import branding_settings from fastid.database.dependencies import engine admin_app = AdminAppFactory( engine, - title=f"{main_settings.title} Admin", - favicon_url=admin_settings.favicon_url, - logo_url=admin_settings.logo_url, + title=branding_settings.admin_swagger_title, + favicon_url=branding_settings.admin_favicon_url, + logo_url=branding_settings.admin_logo_url, base_url="", ).create() diff --git a/fastid/admin/config.py b/fastid/admin/config.py index 33d6f12..8da7862 100644 --- a/fastid/admin/config.py +++ b/fastid/admin/config.py @@ -1,19 +1,14 @@ from pydantic_settings import SettingsConfigDict -from fastid.core.config import main_settings -from fastid.core.schemas import BaseSettings +from fastid.core.schemas import ENV_PREFIX, BaseSettings class AdminSettings(BaseSettings): enabled: bool = True email: str = "admin@fastid.com" password: str = "admin" - favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png" - logo_url: str = "https://fastapi.tiangolo.com/img/logo-margin/logo-teal.png" - model_config = SettingsConfigDict(env_prefix="admin_") + model_config = SettingsConfigDict(env_prefix=f"{ENV_PREFIX}admin_") -favicon_url = f"{main_settings.base_url}/static/assets/favicon.png" -logo_url = f"{main_settings.base_url}/static/assets/logo_text.png" -admin_settings = AdminSettings(favicon_url=favicon_url, logo_url=logo_url) +admin_settings = AdminSettings() diff --git a/fastid/api/app.py b/fastid/api/app.py index b315874..960ea6a 100644 --- a/fastid/api/app.py +++ b/fastid/api/app.py @@ -1,29 +1,31 @@ +from fastid.api.config import api_settings from fastid.api.factory import APIAppFactory from fastid.core.base import Plugin -from fastid.core.config import cors_settings, main_settings +from fastid.core.config import branding_settings, core_settings from fastid.database.dependencies import engine -from fastid.plugins.obs.config import obs_settings -from fastid.plugins.obs.metrics import MetricsPlugin -from fastid.plugins.obs.tracing import TracingPlugin +from fastid.plugins.observability.config import observability_settings +from fastid.plugins.observability.metrics import MetricsPlugin +from fastid.plugins.observability.tracing import TracingPlugin plugins: list[Plugin] = [] -# Must be last plugin -if obs_settings.enabled: - metrics_plugin = MetricsPlugin(app_name=main_settings.discovery_name) +# Must be last plugins +if observability_settings.metrics_enabled: + metrics_plugin = MetricsPlugin(app_name=branding_settings.service_name) + plugins.append(metrics_plugin) +if observability_settings.tracing_enabled: tracing_plugin = TracingPlugin( - app_name=main_settings.discovery_name, - export_url=obs_settings.tempo_url, + app_name=branding_settings.service_name, + export_url=observability_settings.tempo_url, instrument=["logger", "httpx", "sqlalchemy"], engine=engine, ) - plugins += [metrics_plugin, tracing_plugin] + plugins.append(tracing_plugin) api_app = APIAppFactory( - title=main_settings.title, - version=main_settings.version, - base_url=main_settings.api_path, - allow_origins=cors_settings.origins, - allow_origin_regex=cors_settings.origin_regex, + title=branding_settings.api_swagger_title, + base_url=core_settings.api_path, + allow_origins=api_settings.cors_origins, + allow_origin_regex=api_settings.cors_origin_regex, plugins=plugins, ).create() diff --git a/fastid/api/config.py b/fastid/api/config.py new file mode 100644 index 0000000..020ea72 --- /dev/null +++ b/fastid/api/config.py @@ -0,0 +1,15 @@ +from collections.abc import Sequence + +from pydantic_settings import SettingsConfigDict + +from fastid.core.schemas import ENV_PREFIX, BaseSettings + + +class APISettings(BaseSettings): + cors_origins: Sequence[str] = ("*",) + cors_origin_regex: str | None = None + + model_config = SettingsConfigDict(env_prefix=f"{ENV_PREFIX}api_") + + +api_settings = APISettings() diff --git a/fastid/core/middlewares/cors.py b/fastid/api/cors.py similarity index 100% rename from fastid/core/middlewares/cors.py rename to fastid/api/cors.py diff --git a/fastid/auth/config.py b/fastid/auth/config.py index 013681e..aa9788e 100644 --- a/fastid/auth/config.py +++ b/fastid/auth/config.py @@ -1,9 +1,7 @@ from collections.abc import Sequence from pathlib import Path -from pydantic_settings import SettingsConfigDict - -from fastid.core.config import main_settings +from fastid.core.config import core_settings from fastid.core.schemas import BaseSettings @@ -19,33 +17,31 @@ class AuthSettings(BaseSettings): hash_schemas: Sequence[str] = ["argon2", "bcrypt"] # Supports reverse compatibility hash_default: str = "argon2" - argon2_time_cost: int = 3 - argon2_memory_cost: int = 65536 - argon2_parallelism: int = 4 + argon2_time_cost: int = 2 + argon2_memory_cost: int = 19456 + argon2_parallelism: int = 1 argon2_salt_len: int = 16 argon2_hash_len: int = 32 @property def issuer(self) -> str: - return main_settings.base_url + return core_settings.frontend_url @property def authorization_endpoint(self) -> str: - return f"{main_settings.base_url}/authorize" + return f"{core_settings.frontend_url}/authorize" @property def token_endpoint(self) -> str: - return f"{main_settings.api_url}/token" + return f"{core_settings.api_url}/token" @property def userinfo_endpoint(self) -> str: - return f"{main_settings.api_url}/userinfo" + return f"{core_settings.api_url}/userinfo" @property def jwks_uri(self) -> str: - return f"{main_settings.base_url}/.well-known/jwks.json" - - model_config = SettingsConfigDict(env_prefix="auth_") + return f"{core_settings.frontend_url}/.well-known/jwks.json" auth_settings = AuthSettings() diff --git a/fastid/auth/dependencies.py b/fastid/auth/dependencies.py index 2948122..5046866 100644 --- a/fastid/auth/dependencies.py +++ b/fastid/auth/dependencies.py @@ -3,13 +3,13 @@ from fastapi import Depends, Request from fastapi.security import APIKeyCookie, APIKeyHeader, OAuth2PasswordBearer -from fastlink.integrations.fastapi.transport import CookieTransport, HeaderTransport from fastid.api.exceptions import ClientError from fastid.auth.config import auth_settings from fastid.auth.models import User from fastid.auth.use_cases import AuthUseCases from fastid.auth.utils import AuthBus +from fastid.security.transport import CookieTransport, HeaderTransport auth_flows: Iterable[Callable[..., Any]] = [ APIKeyHeader(name="Authorization", scheme_name="BearerToken", auto_error=False), diff --git a/fastid/auth/grants.py b/fastid/auth/grants.py index bf0b22a..d60498f 100644 --- a/fastid/auth/grants.py +++ b/fastid/auth/grants.py @@ -1,20 +1,13 @@ from abc import abstractmethod from typing import Any -from fastlink.schemas import ( - OAuth2AuthorizationCodeRequest, - OAuth2Callback, - OAuth2PasswordRequest, - OAuth2RefreshTokenRequest, - TokenResponse, -) - from fastid.apps.exceptions import ( InvalidAuthorizationCodeError, InvalidClientCredentialsError, ) from fastid.apps.models import App from fastid.apps.repositories import AppClientIDSpecification +from fastid.apps.schemas import AppDTO from fastid.auth.config import auth_settings from fastid.auth.exceptions import ( EmailNotFoundError, @@ -24,11 +17,24 @@ ) from fastid.auth.models import User from fastid.auth.repositories import EmailUserSpecification -from fastid.auth.schemas import AuthorizationResponse, OAuth2ConsentRequest, PayloadResponse, UserDTO +from fastid.auth.schemas import ( + AuthorizationResponse, + OAuth2AuthorizationCodeRequest, + OAuth2Callback, + OAuth2ClientCredentialsRequest, + OAuth2ConsentRequest, + OAuth2PasswordRequest, + OAuth2RefreshTokenRequest, + PayloadResponse, + SubscriberType, + TokenResponse, + UserDTO, +) from fastid.cache.dependencies import CacheDep from fastid.cache.exceptions import KeyNotFoundError from fastid.core.base import UseCase -from fastid.database.dependencies import UOWDep +from fastid.core.timer import Timer +from fastid.database.dependencies import UOWDep, transactional from fastid.database.exceptions import NoResultFoundError from fastid.database.utils import UUIDv7, uuid from fastid.security.crypto import generate_otp @@ -51,16 +57,17 @@ async def validate_client(self, client_id: str) -> App: except NoResultFoundError as e: raise InvalidClientCredentialsError from e + @transactional async def authenticate_client(self, client_id: str, client_secret: str) -> App: app = await self.validate_client(client_id) app.verify_secret(client_secret) return app def grant(self, user: User, scope: str) -> AuthorizationResponse: - tokens = self.issue_tokens(user, scope) - return self.get_auth_response(user, scope, tokens) + tokens = self._issue_tokens(user, scope) + return self._get_auth_response(scope, tokens, user) - def issue_tokens(self, user: User, scope: str) -> dict[str, dict[str, Any]]: + def _issue_tokens(self, user: User, scope: str) -> dict[str, dict[str, Any]]: self._check_scope(user, scope) tokens = { token_type: {"is_issued": False, "token": None, "payload": None} @@ -73,16 +80,29 @@ def issue_tokens(self, user: User, scope: str) -> dict[str, dict[str, Any]]: self._issue_it(user, tokens) return tokens - def get_auth_response(self, user: User, scope: str, tokens: dict[str, Any]) -> AuthorizationResponse: + def _get_auth_response(self, scope: str, tokens: dict[str, Any], user: User) -> AuthorizationResponse: user_dto = UserDTO.model_validate(user) - payload = PayloadResponse( + payload = self._get_payload_response(tokens) + token = self._get_token_response(scope, tokens) + return AuthorizationResponse(user=user_dto, payload=payload, token=token) + + @staticmethod + def _check_scope(user: User, scope: str) -> None: + if "admin" in scope and not user.is_superuser: + raise NoPermissionError + + @staticmethod + def _get_payload_response(tokens: dict[str, Any]) -> PayloadResponse: + return PayloadResponse( access_token=tokens["access"]["payload"], id_token=tokens["id"]["payload"], refresh_token=tokens["refresh"]["payload"], ) + + def _get_token_response(self, scope: str, tokens: dict[str, Any]) -> TokenResponse: token_id = str(uuid()) expires_in = self.token_backend.get_lifetime("access") - token = TokenResponse( + return TokenResponse( token_id=token_id, expires_in=expires_in, scope=scope, @@ -90,12 +110,6 @@ def get_auth_response(self, user: User, scope: str, tokens: dict[str, Any]) -> A id_token=tokens["id"]["token"], refresh_token=tokens["refresh"]["token"], ) - return AuthorizationResponse(user=user_dto, payload=payload, token=token) - - @staticmethod - def _check_scope(user: User, scope: str) -> None: - if "admin" in scope and not user.is_superuser: - raise NoPermissionError def _issue_at(self, user: User, scope: str, tokens: dict[str, dict[str, Any]]) -> None: schema = JWTPayload(sub=str(user.id), scope=scope) @@ -128,12 +142,18 @@ def _issue_it(self, user: User, tokens: dict[str, dict[str, Any]]) -> None: class PasswordGrant(Grant): async def authorize(self, form: OAuth2PasswordRequest) -> AuthorizationResponse: + with Timer("user_email"): + user = await self._find_by_email(form.username) + with Timer("verify_password"): + await user.verify_password(form.password) + with Timer("grant"): + return self.grant(user, form.scope) + + async def _find_by_email(self, email: str) -> User: try: - user = await self.uow.users.find(EmailUserSpecification(form.username)) + return await self.uow.users.find(EmailUserSpecification(email)) except NoResultFoundError as e: raise EmailNotFoundError from e - user.verify_password(form.password) - return self.grant(user, form.scope) class AuthorizationCodeGrant(Grant): @@ -184,3 +204,33 @@ async def authorize( assert content.scope is not None user = await self.uow.users.get(UUIDv7(content.sub)) return self.grant(user, content.scope) + + +class ClientCredentialsGrant(Grant): + async def authorize(self, form: OAuth2ClientCredentialsRequest) -> AuthorizationResponse: + app = await self.authenticate_client(form.client_id, form.client_secret) + self._check_app_scope(app, form.scope) + tokens = self._issue_app_tokens(app, form.scope) + return self._get_app_auth_response(form.scope, tokens, app) + + @staticmethod + def _check_app_scope(app: App, scope: str) -> None: + pass + + def _issue_app_tokens(self, app: App, scope: str) -> dict[str, dict[str, Any]]: + tokens: dict[str, Any] = { + token_type: {"is_issued": False, "token": None, "payload": None} + for token_type in ["access", "refresh", "id"] + } + schema = JWTPayload(sub=str(app.id), scope=scope) + token, payload = self.token_backend.create("access", schema) + tokens["access"]["is_issued"] = True + tokens["access"]["token"] = token + tokens["access"]["payload"] = payload + return tokens + + def _get_app_auth_response(self, scope: str, tokens: dict[str, Any], app: App) -> AuthorizationResponse: + app_dto = AppDTO.model_validate(app) + payload = self._get_payload_response(tokens) + token = self._get_token_response(scope, tokens) + return AuthorizationResponse(app=app_dto, payload=payload, token=token, sub_type=SubscriberType.app) diff --git a/fastid/auth/models.py b/fastid/auth/models.py index 23571cc..d07ff12 100644 --- a/fastid/auth/models.py +++ b/fastid/auth/models.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Self +import anyio.to_thread from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -9,6 +10,8 @@ from fastid.auth.schemas import Contact, ContactType from fastid.database.base import VersionedEntity from fastid.database.utils import uuid +from fastid.email.config import email_settings +from fastid.integrations.config import integration_settings from fastid.notify.config import notify_settings from fastid.notify.schemas import SendOTPRequest, UserAction from fastid.security.crypto import crypt_ctx @@ -86,8 +89,13 @@ def set_password(self, password: str) -> None: def change_email(self, new_email: str) -> None: self.email = new_email - def verify_password(self, password: str) -> None: - if not crypt_ctx.verify(password, self.hashed_password): + async def verify_password(self, password: str) -> None: + valid = await anyio.to_thread.run_sync( + crypt_ctx.verify, + password, + self.hashed_password, + ) + if not valid: raise WrongPasswordError def grant_superuser(self) -> None: @@ -100,10 +108,10 @@ def verify(self) -> None: self.is_verified = True def is_email_available(self) -> bool: - return self.email is not None and notify_settings.smtp_enabled + return self.email is not None and email_settings.smtp_enabled def is_telegram_available(self) -> bool: - return self.telegram_id is not None and notify_settings.telegram_enabled + return self.telegram_id is not None and integration_settings.telegram_notification_enabled def email_contact(self) -> Contact: if not self.is_email_available(): diff --git a/fastid/auth/router.py b/fastid/auth/router.py index ab35217..21b85ec 100644 --- a/fastid/auth/router.py +++ b/fastid/auth/router.py @@ -1,18 +1,25 @@ from typing import Annotated, Any from fastapi import APIRouter, BackgroundTasks, Depends, Form, status -from fastlink.schemas import OAuth2Grant, TokenResponse from starlette.responses import Response from fastid.auth.dependencies import AuthDep, UserDep, cookie_transport, vt_transport from fastid.auth.exceptions import NotSupportedGrantError from fastid.auth.grants import ( AuthorizationCodeGrant, + ClientCredentialsGrant, PasswordGrant, RefreshTokenGrant, ) from fastid.auth.schemas import ( + OAuth2AuthorizationCodeRequest, + OAuth2ClientCredentialsRequest, + OAuth2Grant, + OAuth2PasswordRequest, + OAuth2RefreshTokenRequest, OAuth2TokenRequest, + SubscriberType, + TokenResponse, UserCreate, UserDTO, ) @@ -53,23 +60,27 @@ async def authorize( # noqa: PLR0913 password_grant: Annotated[PasswordGrant, Depends()], authorization_code_grant: Annotated[AuthorizationCodeGrant, Depends()], refresh_token_grant: Annotated[RefreshTokenGrant, Depends()], + client_credentials_grant: Annotated[ClientCredentialsGrant, Depends()], webhooks: WebhooksDep, background: BackgroundTasks, ) -> Any: match form.grant_type: case OAuth2Grant.password: - auth_response = await password_grant.authorize(form.as_password_grant()) + response = await password_grant.authorize(OAuth2PasswordRequest.model_validate(form)) case OAuth2Grant.authorization_code: - auth_response = await authorization_code_grant.authorize(form.as_authorization_code_grant()) + response = await authorization_code_grant.authorize(OAuth2AuthorizationCodeRequest.model_validate(form)) case OAuth2Grant.refresh_token: - auth_response = await refresh_token_grant.authorize(form.as_refresh_token_grant()) + response = await refresh_token_grant.authorize(OAuth2RefreshTokenRequest.model_validate(form)) + case OAuth2Grant.client_credentials: + response = await client_credentials_grant.authorize(OAuth2ClientCredentialsRequest.model_validate(form)) case _: raise NotSupportedGrantError - webhook = SendWebhookRequest( - type=WebhookType.user_login, payload={"user": auth_response.user.model_dump(mode="json")} - ) - background.add_task(webhooks.send, webhook) # pragma: nocover - return cookie_transport.get_login_response(auth_response.token) + if response.sub_type == SubscriberType.user and response.user is not None: + webhook = SendWebhookRequest( + type=WebhookType.user_login, payload={"user": response.user.model_dump(mode="json")} + ) + background.add_task(webhooks.send, webhook) # pragma: nocover + return cookie_transport.get_login_response(response.token) @router.get("/userinfo", response_model=UserDTO, status_code=status.HTTP_200_OK) diff --git a/fastid/auth/schemas.py b/fastid/auth/schemas.py index d9e3832..edf1be2 100644 --- a/fastid/auth/schemas.py +++ b/fastid/auth/schemas.py @@ -1,23 +1,21 @@ from __future__ import annotations -from collections.abc import Mapping # noqa: TCH003 -from enum import StrEnum, auto +from collections.abc import ( + Mapping, # noqa: TCH003 + Sequence, # noqa: TCH003 +) +from enum import auto from typing import Any +from urllib.parse import urlencode -from fastlink.schemas import ( - OAuth2ConsentRequest as BaseOAuth2ConsentRequest, -) -from fastlink.schemas import ( - OAuth2TokenRequest as BaseOAuth2TokenRequest, -) -from fastlink.schemas import ( - TokenResponse, -) from pydantic import ( + ConfigDict, Field, + model_validator, ) -from fastid.core.schemas import BaseModel +from fastid.apps.schemas import AppDTO # noqa: TCH001 +from fastid.core.schemas import BaseEnum, BaseModel from fastid.database.schemas import EntityDTO @@ -38,8 +36,15 @@ class PayloadResponse(BaseModel): id_token: Mapping[str, Any] | None = None +class SubscriberType(BaseEnum): + user = auto() + app = auto() + + class AuthorizationResponse(BaseModel): - user: UserDTO + sub_type: SubscriberType = SubscriberType.user + user: UserDTO | None = None + app: AppDTO | None = None payload: PayloadResponse token: TokenResponse @@ -65,19 +70,314 @@ class UserChangePassword(BaseModel): password: str -class OAuth2TokenRequest(BaseOAuth2TokenRequest): - pass - - -class OAuth2ConsentRequest(BaseOAuth2ConsentRequest): - scope: str = "openid email name" - - class Contact(BaseModel): type: ContactType recipient: dict[str, Any] -class ContactType(StrEnum): +class ContactType(BaseEnum): email = auto() telegram = auto() + + +class OAuth2Grant(BaseEnum): + """ + Grants are methods through which a client can obtain an access token. + """ + + client_credentials = auto() + """ + The client requests an access token from the authorization server's token endpoint by including its client + credentials (client_id and client_secret). This is used when the client is acting on its own behalf. + """ + password = auto() + """ + The resource owner provides the client with its username and password. + The client requests an access token from the authorization server's token endpoint by including the credentials + received from the resource owner. + + This grant type should only be used when there is a high degree of trust between the resource owner and the client. + """ + implicit = auto() + """ + The client directs the resource owner to the authorization server. The resource owner authenticates and + authorizes the client. The authorization server redirects the resource owner back to the client with an access + token. + + This grant type is used for clients that are implemented in a browser using a scripting language such as JavaScript. + """ + authorization_code = auto() + """ + The client directs the resource owner to an authorization server. The resource owner authenticates and authorizes + the client. The authorization server redirects the resource owner back to the client with an authorization code. + The client requests an access token from the authorization server's token endpoint by including the authorization + code received in the previous step. + """ + pkce = auto() + """ + The client directs the resource owner to an authorization server. The resource owner authenticates and authorizes + the client. The authorization server redirects the resource owner back to the client with an authorization code. + The client requests an access token from the authorization server's token endpoint by including the authorization + code received in the previous step and the code verifier. + """ + refresh_token = auto() + """ + The client requests an access token from the authorization server's token endpoint by including the refresh token + """ + + +class OAuth2ConsentRequest(BaseModel): + """ + Consent Request is sent by the client to the authorization server. + Authorization server asks the resource owner to grant permissions to the client. + """ + + response_type: str | None = None + """ + The response type is used to specify the desired authorization processing flow. + """ + client_id: str | None = None + """ + The client ID is a public identifier for the client. + """ + redirect_uri: str | None = None + """ + The redirect URI is used to redirect the user-agent back to the client. + """ + scope: str | None = "openid email name" + """ + The scope is used to specify what access rights an access token has. + """ + state: str | None = None + """ + The state is used to prevent CSRF attacks. + """ + code_challenge: str | None = None + """ + The code challenge is used to verify the authorization code. + """ + code_challenge_method: str | None = None + """ + The code challenge method is used to verify the authorization code. + """ + + @property + def scopes(self) -> Sequence[str]: + if self.scope is None: + return [] + return self.scope.split(" ") + + +class OAuth2Callback(BaseModel): + """ + Callback is sent by the authorization server to the client after the resource owner grants permissions. + """ + + code: str | None = None + """ + The authorization code is used to obtain an access token. + """ + state: str | None = None + """ + The state is used to prevent CSRF attacks. State should be the same as in the consent request. + """ + scope: str | None = None + """ + The scope is used to specify what access rights an access token has. Scope should be the same as in the consent + request. + """ + code_verifier: str | None = None + """ + The code verifier is used to verify the authorization code. + """ + redirect_uri: str | None = None + """ + The redirect URI is used to redirect the user-agent back to the client. Not all providers put it in the callback. + """ + + def get_url(self) -> str: + return f"{self.redirect_uri}?{urlencode(self.model_dump(mode='json', exclude_none=True))}" + + +class OAuth2BaseTokenRequest(BaseModel): + grant_type: OAuth2Grant + """ + The grant type is used to specify the method through which a client can obtain an access token. + """ + client_id: str = "" + """ + The client ID is a public identifier for the client. + + Client credentials may be omitted if the resource server trusts the client. E.g. if you are connecting + backend and frontend of the same application. + """ + client_secret: str = "" + """ + The client secret is a secret known only to the client and the resource server. + + May be omitted if the request comes from public clients (e.g. web browser). + Client credentials may be omitted if the resource server trusts the client. E.g. if you are connecting + backend and frontend of the same application. + """ + scope: str = "" + """ + The scope is used to specify what access rights an access token has. + + Usually, it is passed as query params in the authorization URL, but if the flow does not assume redirection + (like Password Grant Flow), it should be passed in the token request. + """ + + model_config = ConfigDict(from_attributes=True) + + +class OAuth2PasswordRequest(OAuth2BaseTokenRequest): + grant_type: OAuth2Grant = OAuth2Grant.password + username: str = "" + """ + The resource owner's username. Used in Password Grant Flow. + """ + password: str = "" + """ + The resource owner's password. Used in Password Grant Flow. + """ + + +class OAuth2AuthorizationCodeRequest(OAuth2BaseTokenRequest): + grant_type: OAuth2Grant = OAuth2Grant.authorization_code + code: str + """ + The authorization code is used to obtain an access token. Used in Authorization Code Grant. + """ + code_verifier: str = "" + """ + The code verifier is used to verify the authorization code. Used in PKCE Grant. + """ + redirect_uri: str = "" + """ + The redirect URI is passed as second factor to authorize the client. + + Usually we passed the redirect URI in authorization URL, but some providers like Google oblige to pass it in token + request too. + """ + + +class OAuth2RefreshTokenRequest(OAuth2BaseTokenRequest): + grant_type: OAuth2Grant = OAuth2Grant.refresh_token + refresh_token: str + """ + The refresh token is used to obtain a new access token. Used in Refresh Token Grant. + """ + + +class OAuth2ClientCredentialsRequest(OAuth2BaseTokenRequest): + grant_type: OAuth2Grant = OAuth2Grant.client_credentials + + +class OAuth2TokenRequest(BaseModel): + """ + Token Request is sent by the client to the authorization server to obtain an access token. + """ + + grant_type: OAuth2Grant + client_id: str = "" + client_secret: str = "" + username: str = "" + password: str = "" + code: str = "" + code_verifier: str = "" + redirect_uri: str = "" + refresh_token: str = "" + scope: str = "" + + @property + def scopes(self) -> Sequence[str]: + return self.scope.split(" ") + + +class TokenResponse(BaseModel): + """ + Token Response is sent by the authorization server to the client and contains the access token. + """ + + token_id: str | None = None + access_token: str | None = None + refresh_token: str | None = None + id_token: str | None = None + token_type: str | None = "Bearer" + scope: str | None = None + expires_in: int | None = Field( + None, + description="Token expiration time in seconds", + ) + + model_config = ConfigDict(extra="allow") + + +class DiscoveryDocument(BaseModel): + """ + Discovery Document is a JSON document that contains key-value pairs of metadata about the OpenID Connect provider. + """ + + issuer: str | None = None + authorization_endpoint: str | None = None + token_endpoint: str | None = None + userinfo_endpoint: str | None = None + jwks_uri: str | None = None + scopes_supported: Sequence[str] | None = None + response_types_supported: Sequence[str] | None = None + grant_types_supported: Sequence[str] | None = None + subject_types_supported: Sequence[str] | None = None + id_token_signing_alg_values_supported: Sequence[str] | None = None + claims_supported: Sequence[str] | None = None + + model_config = ConfigDict(extra="allow") + + +class ProviderMeta(BaseModel): + """ + ProviderMeta is a metadata about the OpenID Connect provider. + """ + + name: str | None = None + title: str | None = None + server_url: str | None = None + discovery_url: str | None = None + discovery: DiscoveryDocument | None = None + scope: Sequence[str] | None = None + use_state: bool = True + + @model_validator(mode="before") + @classmethod + def check_discovery(cls, values: dict[str, Any]) -> dict[str, Any]: + if values.get("discovery_url") is None and values.get("discovery") is None and values.get("server_url") is None: + msg = "Discovery document is not provided. Please provide a discovery, server_url or discovery_url." + raise ValueError(msg) + return values + + +class OpenID(BaseModel): + id: str = "" + email: str | None = None + first_name: str | None = None + last_name: str | None = None + display_name: str | None = None + picture: str | None = None + + +class JWK(BaseModel): + """ + JSON Web Key (JWK) is a JSON object that represents a cryptographic key. + """ + + kty: str + use: str + alg: str + kid: str + n: str + e: str + + model_config = ConfigDict(extra="allow") + + +class JWKS(BaseModel): + keys: Sequence[JWK] diff --git a/fastid/auth/utils.py b/fastid/auth/utils.py index 4487ca8..7cefb43 100644 --- a/fastid/auth/utils.py +++ b/fastid/auth/utils.py @@ -1,9 +1,9 @@ from collections.abc import Mapping from fastapi import Request -from fastlink.integrations.fastapi.transport import Transport from fastid.auth.exceptions import NoTokenProvidedError +from fastid.security.transport import Transport class AuthBus: diff --git a/fastid/cache/config.py b/fastid/cache/config.py index 8714453..29496d6 100644 --- a/fastid/cache/config.py +++ b/fastid/cache/config.py @@ -1,9 +1,14 @@ -from fastid.core.schemas import BaseSettings +from pydantic_settings import SettingsConfigDict +from fastid.core.config import branding_settings +from fastid.core.schemas import ENV_PREFIX, BaseSettings -class CacheSettings(BaseSettings): - redis_key: str = "fastid" - redis_url: str = "redis://default+changethis@localhost:6379/0" +class RedisSettings(BaseSettings): + url: str = "redis://default+changethis@localhost:6379/0" + major_key: str = branding_settings.service_name -cache_settings = CacheSettings() + model_config = SettingsConfigDict(env_prefix=f"{ENV_PREFIX}redis_") + + +redis_settings = RedisSettings() diff --git a/fastid/cache/dependencies.py b/fastid/cache/dependencies.py index 3779fb6..9af5160 100644 --- a/fastid/cache/dependencies.py +++ b/fastid/cache/dependencies.py @@ -3,14 +3,14 @@ from fastapi import Depends from redis.asyncio import Redis -from fastid.cache.config import cache_settings +from fastid.cache.config import redis_settings from fastid.cache.storage import RedisStorage -redis_client = Redis.from_url(cache_settings.redis_url) +redis_client = Redis.from_url(redis_settings.url) def get_cache() -> RedisStorage: - return RedisStorage(redis_client, key=cache_settings.redis_key) + return RedisStorage(redis_client, key=redis_settings.major_key) CacheDep = Annotated[RedisStorage, Depends(get_cache)] diff --git a/fastid/cache/storage.py b/fastid/cache/storage.py index 39329bd..81547e5 100644 --- a/fastid/cache/storage.py +++ b/fastid/cache/storage.py @@ -47,6 +47,8 @@ async def keys(self, pattern: str = "*") -> set[str]: async def set(self, key: str, value: Any, *, expire: int | None = None) -> str: json_str = json.dumps(value, ensure_ascii=True) + if expire == 0: + return json_str await self.client.set(f"{self.key}:{key}", json_str, ex=expire) return json_str diff --git a/fastid/core/app.py b/fastid/core/app.py index 969b160..709cdb5 100644 --- a/fastid/core/app.py +++ b/fastid/core/app.py @@ -6,7 +6,7 @@ from fastid.admin.app import admin_app from fastid.api.app import api_app from fastid.cache.dependencies import get_cache -from fastid.core.config import main_settings +from fastid.core.config import core_settings from fastid.core.lifespan import LifespanTasks from fastid.database.dependencies import get_uow_raw from fastid.frontend.app import frontend_app @@ -26,6 +26,6 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: core_app = FastAPI(lifespan=lifespan) -core_app.mount(main_settings.api_path, api_app) -core_app.mount(main_settings.admin_path, admin_app) -core_app.mount(main_settings.frontend_path, frontend_app) +core_app.mount(core_settings.api_path, api_app) +core_app.mount(core_settings.admin_path, admin_app) +core_app.mount(core_settings.frontend_path, frontend_app) diff --git a/fastid/core/config.py b/fastid/core/config.py index e360978..2a84e39 100644 --- a/fastid/core/config.py +++ b/fastid/core/config.py @@ -1,43 +1,52 @@ -from collections.abc import Sequence -from enum import StrEnum, auto +from enum import auto +from pydantic import Field from pydantic_settings import SettingsConfigDict -from fastid.core.schemas import BaseSettings +from fastid.core.schemas import ENV_PREFIX, BaseEnum, BaseSettings -class Environment(StrEnum): +class Environment(BaseEnum): local = auto() test = auto() dev = auto() prod = auto() -class MainSettings(BaseSettings): - discovery_name: str = "fastid" - title: str = "FastID" - version: str = "0.1.0" +class CoreSettings(BaseSettings): env: Environment = Environment.local debug: bool = False base_url: str = "http://localhost:8012" api_path: str = "/api/v1" admin_path: str = "/admin" - frontend_path: str = "/" + frontend_path: str = "" @property def api_url(self) -> str: return f"{self.base_url}/{self.api_path[1:]}" - model_config = SettingsConfigDict(env_prefix="main_") + @property + def frontend_url(self) -> str: + if self.frontend_path == "": + return self.base_url + return f"{self.base_url}/{self.frontend_path[1:]}" + +core_settings = CoreSettings() -class CORSSettings(BaseSettings): - origins: Sequence[str] = ("*",) - origin_regex: str | None = None - model_config = SettingsConfigDict(env_prefix="cors_") +class BrandingSettings(BaseSettings): + title: str = "FastID" + service_name: str = "fastid" + api_swagger_title: str = Field(default_factory=lambda data: f"{data['title']} API") + frontend_swagger_title: str = Field(default_factory=lambda data: f"{data['title']} Frontend") + admin_swagger_title: str = Field(default_factory=lambda data: f"{data['title']} Admin") + admin_favicon_url: str = f"{core_settings.frontend_url}/static/assets/favicon.png" + admin_logo_url: str = f"{core_settings.frontend_url}/static/assets/logo_text.png" + notify_from: str = Field(default_factory=lambda data: data["title"]) + + model_config = SettingsConfigDict(env_prefix=f"{ENV_PREFIX}branding_") -main_settings = MainSettings() -cors_settings = CORSSettings() +branding_settings = BrandingSettings() diff --git a/fastid/core/dependencies.py b/fastid/core/dependencies.py index 9015026..a23cd88 100644 --- a/fastid/core/dependencies.py +++ b/fastid/core/dependencies.py @@ -1,6 +1,6 @@ -from fastid.core.config import main_settings +from fastid.core.config import branding_settings from fastid.core.logging.config import config_dict from fastid.core.logging.provider import LogProvider log_provider = LogProvider(config_dict) -log = log_provider.logger(main_settings.discovery_name) +log = log_provider.logger(branding_settings.service_name) diff --git a/fastid/core/schemas.py b/fastid/core/schemas.py index 282a873..ede6365 100644 --- a/fastid/core/schemas.py +++ b/fastid/core/schemas.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import StrEnum from typing import Final from pydantic import BaseModel as PydanticBaseModel @@ -7,13 +8,20 @@ from pydantic_settings import BaseSettings as PydanticBaseSettings from pydantic_settings import SettingsConfigDict +ENV_FILE = ".env" +ENV_PREFIX = "fastid_" + + +class BaseEnum(StrEnum): + pass + class BaseModel(PydanticBaseModel): model_config = ConfigDict(use_enum_values=True) class BaseSettings(PydanticBaseSettings): - model_config = SettingsConfigDict(extra="allow", env_file=".env") + model_config = SettingsConfigDict(extra="allow", env_file=ENV_FILE, env_prefix=ENV_PREFIX) class ErrorResponse(BaseModel): diff --git a/fastid/core/timer.py b/fastid/core/timer.py new file mode 100644 index 0000000..488bcca --- /dev/null +++ b/fastid/core/timer.py @@ -0,0 +1,29 @@ +import time +from types import TracebackType +from typing import Self + +from fastid.core.dependencies import log + + +class Timer: + def __init__(self, name: str, threshold_ms: float = 1.0, slow_ms: float = 10.0) -> None: + self.name = name + self.threshold_ms = threshold_ms + self.slow_ms = slow_ms + self.start_time: float | None = None + self.end_time: float | None = None + + def __enter__(self) -> Self: + self.start_time = time.perf_counter() + return self + + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + self.end_time = time.perf_counter() + assert self.start_time is not None + elapsed_ms = (self.end_time - self.start_time) * 1000 + + if elapsed_ms > self.threshold_ms or exc_type is not None: + status = "ERROR" if exc_type else "SLOW" if elapsed_ms > self.slow_ms else "OK" + log.warning(f"[PROFILE] {status} | {self.name}: {elapsed_ms:.2f}ms") diff --git a/fastid/database/config.py b/fastid/database/config.py index 53efd94..1c55c22 100644 --- a/fastid/database/config.py +++ b/fastid/database/config.py @@ -3,7 +3,7 @@ from pydantic import Field, PositiveInt from pydantic_settings import SettingsConfigDict -from fastid.core.schemas import BaseSettings +from fastid.core.schemas import ENV_PREFIX, BaseSettings class DBSettings(BaseSettings): @@ -24,7 +24,7 @@ class DBSettings(BaseSettings): connect_timeout: PositiveInt | None = Field(default=10) command_timeout: PositiveInt | None = Field(default=30) - model_config = SettingsConfigDict(env_prefix="db_") + model_config = SettingsConfigDict(env_prefix=f"{ENV_PREFIX}db_") db_settings = DBSettings() diff --git a/fastid/core/middlewares/__init__.py b/fastid/email/__init__.py similarity index 100% rename from fastid/core/middlewares/__init__.py rename to fastid/email/__init__.py diff --git a/fastid/notify/clients/smtp.py b/fastid/email/client.py similarity index 100% rename from fastid/notify/clients/smtp.py rename to fastid/email/client.py diff --git a/fastid/email/config.py b/fastid/email/config.py new file mode 100644 index 0000000..8bcbc94 --- /dev/null +++ b/fastid/email/config.py @@ -0,0 +1,22 @@ +from pydantic import Field + +from fastid.core.config import branding_settings +from fastid.core.schemas import BaseSettings + + +class EmailSettings(BaseSettings): + smtp_enabled: bool = False + smtp_ssl: bool = False + smtp_host: str = "mailpit" + smtp_port: int = 1025 + smtp_auth: bool = False + smtp_username: str = "user@example.com" + smtp_password: str = "password" + smtp_from_email: str = Field(default_factory=lambda data: data["smtp_username"]) + + @property + def smtp_from(self) -> str: + return f"{branding_settings.notify_from} <{self.smtp_from_email}>" + + +email_settings = EmailSettings() diff --git a/fastid/email/dependencies.py b/fastid/email/dependencies.py new file mode 100644 index 0000000..2db72e2 --- /dev/null +++ b/fastid/email/dependencies.py @@ -0,0 +1,28 @@ +import smtplib +from typing import Annotated + +from fastapi import Depends + +from fastid.email.client import MailClient +from fastid.email.config import email_settings + + +def get_smtp() -> smtplib.SMTP: + server: smtplib.SMTP + if email_settings.smtp_ssl: + server = smtplib.SMTP_SSL(email_settings.smtp_host, email_settings.smtp_port) + else: + server = smtplib.SMTP(email_settings.smtp_host, email_settings.smtp_port) + if email_settings.smtp_auth: + server.login(email_settings.smtp_username, email_settings.smtp_password) + return server + + +def get_mail(server: Annotated[smtplib.SMTP, Depends(get_smtp)]) -> MailClient: + return MailClient( + server, + mail_from=email_settings.smtp_from, + ) + + +MailDep = Annotated[MailClient, Depends(get_mail)] diff --git a/fastid/frontend/app.py b/fastid/frontend/app.py index 96429f2..d79ad45 100644 --- a/fastid/frontend/app.py +++ b/fastid/frontend/app.py @@ -1,4 +1,4 @@ -from fastid.core.config import main_settings +from fastid.core.config import branding_settings from fastid.frontend.factory import FrontendAppFactory -frontend_app = FrontendAppFactory(title=main_settings.title).create() +frontend_app = FrontendAppFactory(title=branding_settings.frontend_swagger_title).create() diff --git a/fastid/frontend/dependencies.py b/fastid/frontend/dependencies.py index dd2e161..6902517 100644 --- a/fastid/frontend/dependencies.py +++ b/fastid/frontend/dependencies.py @@ -1,7 +1,6 @@ from typing import Annotated from fastapi import Depends -from fastlink.exceptions import FastLinkError from starlette.requests import Request from fastid.api.exceptions import ClientError, UnauthorizedError @@ -9,6 +8,7 @@ from fastid.auth.grants import AuthorizationCodeGrant from fastid.auth.models import User from fastid.auth.schemas import OAuth2ConsentRequest +from fastid.security.exceptions import JWTError from fastid.security.jwt import ( jwt_backend, ) @@ -48,7 +48,7 @@ def is_action_verified( return False try: jwt_backend.validate("verify", token) - except FastLinkError: + except JWTError: return False return True diff --git a/fastid/frontend/factory.py b/fastid/frontend/factory.py index abdc875..a6f9f1d 100644 --- a/fastid/frontend/factory.py +++ b/fastid/frontend/factory.py @@ -6,10 +6,11 @@ from fastid.auth.config import auth_settings from fastid.core.base import AppFactory +from fastid.core.config import branding_settings from fastid.frontend.exceptions import add_exception_handlers from fastid.frontend.router import router as pages_router from fastid.frontend.templating import templates -from fastid.oauth.clients.dependencies import registry +from fastid.oauth.metadata import UI_META routers = [pages_router] @@ -51,7 +52,7 @@ def create(self) -> FastAPI: return app def _set_templates_env(self) -> None: - templates.env.globals["app_title"] = self.title + templates.env.globals["app_title"] = branding_settings.title templates.env.globals["favicon_url"] = self.favicon_url templates.env.globals["logo_url"] = self.logo_url - templates.env.globals["providers_meta"] = registry.metadata + templates.env.globals["providers_meta"] = UI_META diff --git a/fastid/frontend/openid.py b/fastid/frontend/openid.py index ddba03f..2f84352 100644 --- a/fastid/frontend/openid.py +++ b/fastid/frontend/openid.py @@ -2,10 +2,10 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from fastlink.jwt.schemas import JWTPayload -from fastlink.schemas import JWK, JWKS, DiscoveryDocument from fastid.auth.config import auth_settings +from fastid.auth.schemas import JWK, JWKS, DiscoveryDocument +from fastid.security.schemas import JWTPayload discovery_document = DiscoveryDocument( issuer=auth_settings.issuer, diff --git a/fastid/frontend/router.py b/fastid/frontend/router.py index 1905c6a..a753990 100644 --- a/fastid/frontend/router.py +++ b/fastid/frontend/router.py @@ -2,12 +2,11 @@ from fastapi import APIRouter, Depends, Request, Response, status from fastapi.responses import RedirectResponse -from fastlink.schemas import JWKS, DiscoveryDocument from fastid.auth.dependencies import cookie_transport from fastid.auth.grants import AuthorizationCodeGrant from fastid.auth.models import User -from fastid.auth.schemas import OAuth2ConsentRequest +from fastid.auth.schemas import JWKS, DiscoveryDocument, OAuth2ConsentRequest from fastid.frontend.dependencies import ( get_user, get_user_or_none, diff --git a/fastid/notify/clients/__init__.py b/fastid/integrations/__init__.py similarity index 100% rename from fastid/notify/clients/__init__.py rename to fastid/integrations/__init__.py diff --git a/fastid/oauth/clients/__init__.py b/fastid/integrations/base/__init__.py similarity index 100% rename from fastid/oauth/clients/__init__.py rename to fastid/integrations/base/__init__.py diff --git a/fastid/integrations/base/oauth.py b/fastid/integrations/base/oauth.py new file mode 100644 index 0000000..4bab407 --- /dev/null +++ b/fastid/integrations/base/oauth.py @@ -0,0 +1,198 @@ +from collections.abc import AsyncIterator, Sequence +from types import TracebackType +from typing import ( + Any, + ClassVar, + Self, +) +from urllib.parse import urlencode + +import httpx + +from fastid.auth.schemas import DiscoveryDocument, OAuth2Callback, OpenID, ProviderMeta, TokenResponse +from fastid.integrations.constants import MAX_SUCCESS_CODE, MIN_SUCCESS_CODE +from fastid.integrations.exceptions import ( + AuthorizationError, + ClientError, + DiscoveryError, + RedirectURIError, + StateError, + TokenError, + UserinfoError, +) +from fastid.integrations.schemas import LoginResponse, UserinfoResponse +from fastid.integrations.utils import generate_random_state + + +class OAuth2Client: + default_meta: ClassVar[ProviderMeta] + + def __init__( + self, + client_id: str, + client_secret: str, + redirect_uri: str | None = None, + scope: Sequence[str] | None = None, + meta: ProviderMeta | None = None, + ) -> None: + self.meta = meta or self.default_meta + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.use_state = self.meta.use_state + self.discovery_url = self.meta.discovery_url + self._discovery = None + + scope = scope or self.meta.scope + assert scope is not None + self.scope = scope + + if self.meta.discovery is not None: + self._discovery = self.meta.discovery + + if self.meta.server_url is not None: + self.discovery_url = f"{self.meta.server_url}/.well-known/openid-configuration" + + self._token: TokenResponse | None = None + self._client: httpx.AsyncClient | None = None + + @property + def discovery(self) -> DiscoveryDocument: + if self._discovery is None: + msg = "Discovery document is not available. Please discover first." + raise DiscoveryError(msg) + return self._discovery + + @property + def token(self) -> TokenResponse: + if not self._token: + msg = "Token is not available. Please authorize first." + raise TokenError(msg) + return self._token + + @property + def client(self) -> httpx.AsyncClient: + if not self._client: + msg = "Client is not available. Please enter the context." + raise ClientError(msg) + return self._client + + async def convert_token(self, response: dict[str, Any]) -> TokenResponse: + return TokenResponse.model_validate(response) + + async def convert_userinfo(self, response: dict[str, Any]) -> OpenID: # noqa: ARG002 + return OpenID() + + async def discover(self) -> DiscoveryDocument: + assert self.discovery_url is not None + response = await self.client.get(self.discovery_url) + return DiscoveryDocument.model_validate(response.json()) + + async def login_url( + self, + *, + redirect_uri: str | None = None, + state: str | None = None, + params: dict[str, Any] | None = None, + ) -> str: + params = params or {} + if self.use_state: + params |= {"state": state or generate_random_state()} + redirect_uri = redirect_uri or self.redirect_uri + if redirect_uri is None: + msg = "redirect_uri must be provided, either at construction or request time" + raise RedirectURIError(msg) + request_params = { + "response_type": "code", + "client_id": self.client_id, + "scope": " ".join(self.scope), + "redirect_uri": redirect_uri, + **params, + } + return f"{self.discovery.authorization_endpoint}?{urlencode(request_params)}" + + async def login( + self, + callback: OAuth2Callback, + *, + body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> LoginResponse: + request = self._prepare_token_request(callback, body=body, headers=headers) + auth = httpx.BasicAuth(self.client_id, self.client_secret) + response = await self.client.send( + request, + auth=auth, + ) + content = response.json() + if response.status_code < MIN_SUCCESS_CODE or response.status_code > MAX_SUCCESS_CODE: + msg = "Authorization failed: %s" + raise AuthorizationError(msg, content) + token = await self.convert_token(content) + self._token = token + return LoginResponse(token=token, token_raw=content) + + async def userinfo(self) -> UserinfoResponse: + assert self.discovery.userinfo_endpoint is not None + headers = { + "Authorization": f"{self.token.token_type} {self.token.access_token}", + } + response = await self.client.get(self.discovery.userinfo_endpoint, headers=headers) + content = response.json() + if response.status_code < MIN_SUCCESS_CODE or response.status_code > MAX_SUCCESS_CODE: + msg = "Getting userinfo failed: %s" + raise UserinfoError(msg, content) + userinfo = await self.convert_userinfo(content) + return UserinfoResponse(userinfo=userinfo, userinfo_raw=content) + + async def callback(self, callback: OAuth2Callback) -> UserinfoResponse: + await self.login(callback) + return await self.userinfo() + + def _prepare_token_request( + self, + callback: OAuth2Callback, + *, + body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Request: + assert self.discovery.token_endpoint is not None + body = body or {} + headers = headers or {} + headers |= {"Content-Type": "application/x-www-form-urlencoded"} + if self.use_state: + if not callback.state: + msg = "State was not found in the callback" + raise StateError(msg) + body |= {"state": callback.state} + body = { + "grant_type": "authorization_code", + "code": callback.code, + "redirect_uri": callback.redirect_uri or self.redirect_uri, + "client_id": self.client_id, + "client_secret": self.client_secret, + **body, + } + return httpx.Request( + "post", + self.discovery.token_endpoint, + data=body, + headers=headers, + ) + + async def __aenter__(self) -> Self: + self._client = httpx.AsyncClient() + await self._client.__aenter__() + if self._discovery is None: + self._discovery = await self.discover() + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + self._token = None + await self.client.__aexit__(exc_type, exc_value, traceback) + + async def __call__(self) -> AsyncIterator[Self]: + async with self: + yield self diff --git a/fastid/integrations/config.py b/fastid/integrations/config.py new file mode 100644 index 0000000..d4a8831 --- /dev/null +++ b/fastid/integrations/config.py @@ -0,0 +1,31 @@ +from fastid.core.config import core_settings +from fastid.core.schemas import BaseSettings + + +class IntegrationSettings(BaseSettings): + google_oauth_enabled: bool = False + google_client_id: str = "" + google_client_secret: str = "" + + yandex_oauth_enabled: bool = False + yandex_client_id: str = "" + yandex_client_secret: str = "" + + telegram_widget_enabled: bool = False + telegram_notification_enabled: bool = False + telegram_bot_token: str = "" + + @property + def base_authorization_url(self) -> str: + return f"{core_settings.api_url}/oauth/login" + + @property + def base_redirect_url(self) -> str: + return f"{core_settings.api_url}/oauth/callback" + + @property + def base_revoke_url(self) -> str: + return f"{core_settings.api_url}/oauth/revoke" + + +integration_settings = IntegrationSettings() diff --git a/fastid/integrations/constants.py b/fastid/integrations/constants.py new file mode 100644 index 0000000..52625d0 --- /dev/null +++ b/fastid/integrations/constants.py @@ -0,0 +1,3 @@ +MIN_SUCCESS_CODE = 200 +MAX_SUCCESS_CODE = 299 +TELEGRAM_BOT_TOKEN_VALUES_NUMBER = 2 diff --git a/fastid/integrations/dependencies.py b/fastid/integrations/dependencies.py new file mode 100644 index 0000000..b60271c --- /dev/null +++ b/fastid/integrations/dependencies.py @@ -0,0 +1,67 @@ +from typing import Annotated + +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from fastapi import Depends + +from fastid.core.config import core_settings +from fastid.integrations.config import integration_settings +from fastid.integrations.google.oauth import GoogleSSO +from fastid.integrations.registries import OAuth2Registry +from fastid.integrations.telegram.login import TelegramLoginWidget +from fastid.integrations.telegram.notifications import TelegramNotificationClient +from fastid.integrations.yandex.oauth import YandexSSO +from fastid.oauth.exceptions import OAuthProviderDisabledError + +registry = OAuth2Registry() + + +@registry.provider("google") +def get_google_sso() -> GoogleSSO: + if not integration_settings.google_oauth_enabled: + raise OAuthProviderDisabledError + return GoogleSSO( + integration_settings.google_client_id, + integration_settings.google_client_secret, + f"{integration_settings.base_redirect_url}/google", + ) + + +@registry.provider("yandex") +def get_yandex_sso() -> YandexSSO: + if not integration_settings.yandex_oauth_enabled: + raise OAuthProviderDisabledError + return YandexSSO( + integration_settings.yandex_client_id, + integration_settings.yandex_client_secret, + f"{integration_settings.base_redirect_url}/yandex", + ) + + +def get_registry() -> OAuth2Registry: + return registry + + +OAuth2RegistryDep = Annotated[OAuth2Registry, Depends(get_registry)] + + +def get_telegram_widget() -> TelegramLoginWidget: + return TelegramLoginWidget( + integration_settings.telegram_bot_token, + f"{core_settings.api_url}/oauth/redirect/telegram", + f"{core_settings.api_url}/oauth/callback/telegram", + ) + + +TelegramWidgetDep = Annotated[TelegramLoginWidget, Depends(get_telegram_widget)] + + +def get_bot() -> Bot: + return Bot(integration_settings.telegram_bot_token, default=DefaultBotProperties(parse_mode="Markdown")) + + +def get_telegram_nc(bot: Annotated[Bot, Depends(get_bot)]) -> TelegramNotificationClient: + return TelegramNotificationClient(bot) + + +TelegramNotificationsDep = Annotated[TelegramNotificationClient, Depends(get_telegram_nc)] diff --git a/fastid/integrations/exceptions.py b/fastid/integrations/exceptions.py new file mode 100644 index 0000000..ef69b1d --- /dev/null +++ b/fastid/integrations/exceptions.py @@ -0,0 +1,42 @@ +class IntegrationError(Exception): + pass + + +class TokenError(IntegrationError): + pass + + +class DiscoveryError(IntegrationError): + pass + + +class ClientError(IntegrationError): + pass + + +class RedirectURIError(IntegrationError): + pass + + +class AuthorizationError(IntegrationError): + pass + + +class UserinfoError(IntegrationError): + pass + + +class StateError(IntegrationError): + pass + + +class InvalidTokenTypeError(IntegrationError): + pass + + +class HashMismatchError(IntegrationError): + pass + + +class ExpirationError(IntegrationError): + pass diff --git a/fastid/plugins/obs/__init__.py b/fastid/integrations/google/__init__.py similarity index 100% rename from fastid/plugins/obs/__init__.py rename to fastid/integrations/google/__init__.py diff --git a/fastid/integrations/google/oauth.py b/fastid/integrations/google/oauth.py new file mode 100644 index 0000000..02a2998 --- /dev/null +++ b/fastid/integrations/google/oauth.py @@ -0,0 +1,27 @@ +from typing import Any + +from fastid.auth.schemas import OpenID, ProviderMeta +from fastid.integrations.base.oauth import OAuth2Client +from fastid.integrations.exceptions import IntegrationError + + +class GoogleSSO(OAuth2Client): + default_meta = ProviderMeta( + name="google", title="Google", server_url="https://accounts.google.com", scope=["openid", "email", "profile"] + ) + + async def convert_userinfo( + self, + response: dict[str, Any], + ) -> OpenID: + if response.get("email_verified"): + return OpenID( + email=response.get("email"), + id=response.get("sub"), + first_name=response.get("given_name"), + last_name=response.get("family_name"), + display_name=response.get("name"), + picture=response.get("picture"), + ) + msg = f"User {response.get('email')} is not verified with Google" + raise IntegrationError(msg) diff --git a/fastid/integrations/registries.py b/fastid/integrations/registries.py new file mode 100644 index 0000000..6442ce8 --- /dev/null +++ b/fastid/integrations/registries.py @@ -0,0 +1,26 @@ +from collections.abc import Callable, MutableMapping + +from fastid.integrations.base.oauth import OAuth2Client +from fastid.oauth.exceptions import OAuthProviderNotFoundError + + +class OAuth2Registry: # pragma: nocover + def __init__(self) -> None: + self._providers: MutableMapping[str, Callable[[], OAuth2Client]] = {} + + def provider( + self, + name: str, + ) -> Callable[[Callable[[], OAuth2Client]], Callable[[], OAuth2Client]]: + def wrapper( + factory: Callable[[], OAuth2Client], + ) -> Callable[[], OAuth2Client]: + self._providers[name] = factory + return factory + + return wrapper + + def get(self, name: str) -> OAuth2Client: + if name not in self._providers: + raise OAuthProviderNotFoundError + return self._providers[name]() diff --git a/fastid/integrations/schemas.py b/fastid/integrations/schemas.py new file mode 100644 index 0000000..28290d8 --- /dev/null +++ b/fastid/integrations/schemas.py @@ -0,0 +1,30 @@ +from typing import Any + +from fastid.auth.schemas import OpenID, TokenResponse +from fastid.core.schemas import BaseModel + + +class LoginResponse(BaseModel): + token: TokenResponse + token_raw: dict[str, Any] + + +class UserinfoResponse(BaseModel): + userinfo: OpenID + userinfo_raw: dict[str, Any] + + +class TelegramCallback(BaseModel): + id: int + first_name: str + last_name: str | None = None + username: str | None = None + photo_url: str | None = None + auth_date: int + hash: str + + +class TelegramWidget(BaseModel): + bot_username: str + callback_url: str + scope: str = "write" diff --git a/fastid/integrations/telegram/__init__.py b/fastid/integrations/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastid/integrations/telegram/login.py b/fastid/integrations/telegram/login.py new file mode 100644 index 0000000..58fa58e --- /dev/null +++ b/fastid/integrations/telegram/login.py @@ -0,0 +1,128 @@ +from collections.abc import AsyncIterator, Sequence +from types import TracebackType +from typing import Any, Self +from urllib.parse import urlencode + +from aiogram import Bot +from aiogram.utils.token import TokenValidationError + +from fastid.auth.schemas import DiscoveryDocument, OpenID, ProviderMeta +from fastid.integrations.exceptions import TokenError +from fastid.integrations.schemas import TelegramCallback, TelegramWidget, UserinfoResponse +from fastid.integrations.utils import check_expiration, replace_localhost, verify_hmac_sha256 + + +class TelegramLoginWidget: + meta = ProviderMeta( + name="telegram", + title="Telegram", + discovery=DiscoveryDocument(authorization_endpoint="https://oauth.telegram.org/auth"), + scope=["write"], + ) + widget_js_url = "https://telegram.org/js/telegram-widget.js?22" + + def __init__( + self, + bot_token: str, + widget_uri: str | None = None, + redirect_uri: str | None = None, + scope: Sequence[str] | None = None, + expires_in: int = 300, + **bot_kwargs: Any, + ) -> None: + assert self.meta.scope is not None + + self.bot_token = bot_token + self.redirect_uri = redirect_uri + self.widget_uri = widget_uri + self.scope = scope or self.meta.scope + self.expires_in = expires_in + + try: + self.bot = Bot(bot_token, **bot_kwargs) + except TokenValidationError as e: + raise TokenError from e + + self.bot_id = self.bot.id + self._callback: TelegramCallback | None = None + + @property + def discovery(self) -> DiscoveryDocument: + assert self.meta.discovery is not None + return self.meta.discovery + + @staticmethod + def convert_userinfo(response: dict[str, Any]) -> OpenID: + first_name, last_name = ( + response["first_name"], + response.get("last_name"), + ) + display_name = f"{first_name} {last_name}" if last_name else first_name + return OpenID( + id=str(response["id"]), + first_name=first_name, + last_name=last_name, + display_name=display_name, + picture=response.get("photo_url"), + ) + + async def widget_html(self) -> str: + info = await self.widget_data() + return f""" + + + Telegram OAuth + + + + + + """ + + async def login_url( + self, + *, + widget_uri: str | None = None, + params: dict[str, Any] | None = None, + ) -> str: + params = params or {} + widget_uri = replace_localhost(widget_uri or self.widget_uri) + scope = self.scope + login_params = { + "bot_id": self.bot_id, + "origin": widget_uri, + "request_access": scope, + **params, + } + return f"{self.discovery.authorization_endpoint}?{urlencode(login_params)}" + + async def verify(self, callback: TelegramCallback) -> UserinfoResponse: + response = callback.model_dump(exclude_none=True) + response_copy = response.copy() + expected_hash = response.pop("hash") + verify_hmac_sha256(response, expected_hash, self.bot_token) + check_expiration(response, self.expires_in) + userinfo = self.convert_userinfo(response) + return UserinfoResponse(userinfo_raw=response_copy, userinfo=userinfo) + + async def widget_data(self) -> TelegramWidget: + me = await self.bot.me() + return TelegramWidget( + bot_username=me.username, + callback_url=replace_localhost(self.redirect_uri or self.redirect_uri), + scope=" ".join(self.scope), + ) + + async def __aenter__(self) -> Self: + await self.bot.__aenter__() + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + await self.bot.__aexit__(exc_type, exc_value, traceback) + + async def __call__(self) -> AsyncIterator[Self]: + async with self: + yield self diff --git a/fastid/notify/clients/telegram.py b/fastid/integrations/telegram/notifications.py similarity index 100% rename from fastid/notify/clients/telegram.py rename to fastid/integrations/telegram/notifications.py diff --git a/fastid/integrations/utils.py b/fastid/integrations/utils.py new file mode 100644 index 0000000..0d262d2 --- /dev/null +++ b/fastid/integrations/utils.py @@ -0,0 +1,39 @@ +import base64 +import datetime +import hashlib +import hmac +import os +from typing import Any + +from fastid.integrations.exceptions import ExpirationError, HashMismatchError + + +def generate_random_state(length: int = 64) -> str: + bytes_length = int(length * 3 / 4) + return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8") + + +def replace_localhost(url: Any) -> str: + return str(url).replace("localhost", "127.0.0.1", 1) + + +def compute_hmac_sha256(payload: dict[str, Any], secret_key: str) -> str: + data_check_string = "\n".join(sorted(f"{k}={v}" for k, v in payload.items())) + return hmac.new( + hashlib.sha256(secret_key.encode()).digest(), + data_check_string.encode(), + "sha256", + ).hexdigest() + + +def verify_hmac_sha256(payload: dict[str, Any], expected_hash: str, secret_key: str) -> None: + computed_hash = compute_hmac_sha256(payload, secret_key) + if not hmac.compare_digest(computed_hash, expected_hash): + raise HashMismatchError + + +def check_expiration(payload: dict[str, Any], expires_in: int = 300) -> None: + dt = datetime.datetime.fromtimestamp(payload["auth_date"], tz=datetime.UTC) + now = datetime.datetime.now(tz=datetime.UTC) + if now - dt > datetime.timedelta(seconds=expires_in): + raise ExpirationError diff --git a/fastid/integrations/yandex/__init__.py b/fastid/integrations/yandex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastid/integrations/yandex/oauth.py b/fastid/integrations/yandex/oauth.py new file mode 100644 index 0000000..c4f7f0c --- /dev/null +++ b/fastid/integrations/yandex/oauth.py @@ -0,0 +1,34 @@ +from typing import Any + +from fastid.auth.schemas import DiscoveryDocument, OpenID, ProviderMeta +from fastid.integrations.base.oauth import OAuth2Client + + +class YandexSSO(OAuth2Client): + default_meta = ProviderMeta( + name="yandex", + title="Yandex", + discovery=DiscoveryDocument( + authorization_endpoint="https://oauth.yandex.ru/authorize", + token_endpoint="https://oauth.yandex.ru/token", # noqa: S106 + userinfo_endpoint="https://login.yandex.ru/info", + ), + scope=["login:email", "login:info", "login:avatar"], + ) + avatar_url = "https://avatars.yandex.net/get-yapic" + + async def convert_userinfo( + self, + response: dict[str, Any], + ) -> OpenID: + picture = None + if (avatar_id := response.get("default_avatar_id")) is not None: + picture = f"{self.avatar_url}/{avatar_id}/islands-200" + return OpenID( + email=response.get("default_email"), + display_name=response.get("display_name"), + id=response.get("id"), + first_name=response.get("first_name"), + last_name=response.get("last_name"), + picture=picture, + ) diff --git a/fastid/notify/clients/dependencies.py b/fastid/notify/clients/dependencies.py deleted file mode 100644 index 9061a56..0000000 --- a/fastid/notify/clients/dependencies.py +++ /dev/null @@ -1,41 +0,0 @@ -import smtplib -from typing import Annotated - -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from fastapi import Depends - -from fastid.notify.clients.smtp import MailClient -from fastid.notify.clients.telegram import TelegramNotificationClient -from fastid.notify.config import notify_settings -from fastid.oauth.config import telegram_settings - - -def get_smtp() -> smtplib.SMTP: - server: smtplib.SMTP - if notify_settings.smtp_ssl: - server = smtplib.SMTP_SSL(notify_settings.smtp_host, notify_settings.smtp_port) - else: - server = smtplib.SMTP(notify_settings.smtp_host, notify_settings.smtp_port) - if notify_settings.smtp_auth: - server.login(notify_settings.smtp_username, notify_settings.smtp_password) - return server - - -def get_mail(server: Annotated[smtplib.SMTP, Depends(get_smtp)]) -> MailClient: - return MailClient( - server, - mail_from=notify_settings.smtp_from, - ) - - -def get_bot() -> Bot: - return Bot(telegram_settings.bot_token, default=DefaultBotProperties(parse_mode="Markdown")) - - -def get_telegram_nc(bot: Annotated[Bot, Depends(get_bot)]) -> TelegramNotificationClient: - return TelegramNotificationClient(bot) - - -MailDep = Annotated[MailClient, Depends(get_mail)] -TelegramDep = Annotated[TelegramNotificationClient, Depends(get_telegram_nc)] diff --git a/fastid/notify/config.py b/fastid/notify/config.py index e64da10..fe87b95 100644 --- a/fastid/notify/config.py +++ b/fastid/notify/config.py @@ -1,40 +1,11 @@ from collections.abc import Mapping -from jinja2 import Environment, FileSystemLoader -from pydantic_settings import SettingsConfigDict - from fastid.auth.schemas import ContactType from fastid.core.schemas import BaseSettings -class NotifySettings(BaseSettings): - app_name: str = "FastID" - - smtp_enabled: bool = False - smtp_ssl: bool = False - smtp_host: str = "mailpit" - smtp_port: int = 1025 - smtp_from_email: str = "fastid@localhost" - smtp_auth: bool = False - smtp_username: str = "user@example.com" - smtp_password: str = "password" - - telegram_enabled: bool = False - +class NotificationSettings(BaseSettings): contact_priority: Mapping[ContactType, int] = {ContactType.telegram: 0, ContactType.email: 1} - @property - def enabled(self) -> bool: - return self.smtp_enabled or self.telegram_enabled - - @property - def smtp_from(self) -> str: - return f"{self.app_name} <{self.smtp_from_email}>" - - model_config = SettingsConfigDict(env_prefix="notify_") - - -notify_settings = NotifySettings() -jinja_env = Environment(autoescape=True, loader=FileSystemLoader("templates")) -jinja_env.globals["from_name"] = notify_settings.app_name +notify_settings = NotificationSettings() diff --git a/fastid/notify/models.py b/fastid/notify/models.py index 2649914..d784184 100644 --- a/fastid/notify/models.py +++ b/fastid/notify/models.py @@ -1,19 +1,20 @@ from __future__ import annotations -from enum import StrEnum, auto +from enum import auto from typing import TYPE_CHECKING, Any from uuid import UUID # noqa: TCH003 from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship +from fastid.core.schemas import BaseEnum from fastid.database.base import Entity, VersionedEntity if TYPE_CHECKING: from fastid.auth.models import User -class NotificationType(StrEnum): +class NotificationType(BaseEnum): email = auto() telegram = auto() diff --git a/fastid/notify/schemas.py b/fastid/notify/schemas.py index a081b24..24db9ad 100644 --- a/fastid/notify/schemas.py +++ b/fastid/notify/schemas.py @@ -1,12 +1,11 @@ -from enum import StrEnum from typing import Any from pydantic import Field -from fastid.core.schemas import BaseModel +from fastid.core.schemas import BaseEnum, BaseModel -class UserAction(StrEnum): +class UserAction(BaseEnum): change_email = "change-email" change_password = "change-password" # noqa: S105 # pragma: allowlist secret delete_account = "delete-account" diff --git a/fastid/notify/templating.py b/fastid/notify/templating.py new file mode 100644 index 0000000..0069ba9 --- /dev/null +++ b/fastid/notify/templating.py @@ -0,0 +1,6 @@ +from jinja2 import Environment, FileSystemLoader + +from fastid.core.config import branding_settings + +jinja_env = Environment(autoescape=True, loader=FileSystemLoader("templates")) +jinja_env.globals["from_name"] = branding_settings.notify_from diff --git a/fastid/notify/use_cases.py b/fastid/notify/use_cases.py index 32d40f1..bcb8e05 100644 --- a/fastid/notify/use_cases.py +++ b/fastid/notify/use_cases.py @@ -10,14 +10,15 @@ from fastid.core.base import UseCase from fastid.database.dependencies import UOWRawDep, transactional from fastid.database.exceptions import NoResultFoundError -from fastid.notify.clients.dependencies import MailDep, TelegramDep -from fastid.notify.config import jinja_env, notify_settings +from fastid.email.config import email_settings +from fastid.email.dependencies import MailDep +from fastid.integrations.config import integration_settings +from fastid.integrations.dependencies import TelegramNotificationsDep from fastid.notify.exceptions import ( InvalidContactTypeError, MethodDisabledError, NoEmailError, NoTelegramIDError, - NotificationDisabledError, TemplateNotFoundError, WrongCodeError, ) @@ -29,6 +30,7 @@ UserAction, VerifyOTPRequest, ) +from fastid.notify.templating import jinja_env from fastid.security.crypto import generate_otp from fastid.security.jwt import jwt_backend from fastid.security.schemas import JWTPayload @@ -39,7 +41,7 @@ def __init__( self, uow: UOWRawDep, # Due to background nature of notification use cases, use raw dependency mail: MailDep, - telegram: TelegramDep, + telegram: TelegramNotificationsDep, cache: CacheDep, ) -> None: self.uow = uow @@ -49,7 +51,7 @@ def __init__( @transactional async def push_email(self, user: User, dto: PushNotificationRequest, contact: Contact | None = None) -> None: - if not notify_settings.smtp_enabled: + if not email_settings.smtp_enabled: raise MethodDisabledError if contact is None: try: @@ -74,7 +76,7 @@ async def push_email(self, user: User, dto: PushNotificationRequest, contact: Co @transactional async def push_telegram(self, user: User, dto: PushNotificationRequest, contact: Contact | None = None) -> None: - if not notify_settings.telegram_enabled: + if not integration_settings.telegram_notification_enabled: raise MethodDisabledError if contact is None: try: @@ -100,8 +102,6 @@ async def push_telegram(self, user: User, dto: PushNotificationRequest, contact: await self.uow.notifications.add(notification) async def push(self, user: User, dto: PushNotificationRequest, contact: Contact | None = None) -> None: - if not notify_settings.enabled: - raise NotificationDisabledError if contact is None: contact = user.find_priority_contact() match contact.type: @@ -113,8 +113,6 @@ async def push(self, user: User, dto: PushNotificationRequest, contact: Contact raise InvalidContactTypeError async def push_otp(self, user: User | None, dto: SendOTPRequest) -> None: - if not notify_settings.enabled: - raise NotificationDisabledError if dto.action == UserAction.recover_password: assert dto.email is not None user = await self._get_user_by_email(dto.email) diff --git a/fastid/notify/utils.py b/fastid/notify/utils.py index 8033a71..2fb8312 100644 --- a/fastid/notify/utils.py +++ b/fastid/notify/utils.py @@ -1,7 +1,7 @@ from collections.abc import Iterable from pathlib import Path -from fastid.notify.config import notify_settings +from fastid.core.config import branding_settings from fastid.notify.models import EmailTemplate, TelegramTemplate @@ -11,7 +11,7 @@ def collect_email_templates() -> Iterable[EmailTemplate]: with Path("templates/notifications/html/code.html").open() as f: code = EmailTemplate(slug="code", subject="Your verification code", source=f.read()) with Path("templates/notifications/html/welcome.html").open() as f: - welcome = EmailTemplate(slug="welcome", subject=f"Welcome to {notify_settings.app_name}", source=f.read()) + welcome = EmailTemplate(slug="welcome", subject=f"Welcome to {branding_settings.title}", source=f.read()) return [base, code, welcome] diff --git a/fastid/oauth/clients/dependencies.py b/fastid/oauth/clients/dependencies.py deleted file mode 100644 index cea5adc..0000000 --- a/fastid/oauth/clients/dependencies.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Annotated - -from fastapi import Depends -from fastlink import GoogleSSO, TelegramSSO, YandexSSO - -from fastid.core.config import main_settings -from fastid.oauth.clients.registry import SSORegistry -from fastid.oauth.config import ( - google_settings, - oauth_settings, - telegram_settings, - yandex_settings, -) - -registry = SSORegistry( - base_authorization_url=oauth_settings.base_authorization_url, - base_revoke_url=oauth_settings.base_revoke_url, -) - - -@registry.provider( - "google", - title="Google", - icon="fa-google", - color="#F44336", - enabled=google_settings.enabled, -) -def get_google_sso() -> GoogleSSO: - return GoogleSSO( - google_settings.client_id, - google_settings.client_secret, - f"{oauth_settings.base_redirect_url}/google", - ) - - -@registry.provider( # type: ignore[arg-type] - "telegram", - title="Telegram", - icon="fa-telegram", - color="#03A9F4", - enabled=telegram_settings.oauth_enabled, -) -def get_telegram_sso() -> TelegramSSO: - return TelegramSSO( - telegram_settings.bot_token, - f"{main_settings.api_url}/oauth/redirect/telegram", - f"{main_settings.api_url}/oauth/callback/telegram", - ) - - -@registry.provider( - "yandex", - title="Yandex", - icon="fa-yandex", - color="#EA4335", - enabled=yandex_settings.enabled, -) -def get_yandex_sso() -> YandexSSO: - return YandexSSO( - yandex_settings.client_id, - yandex_settings.client_secret, - f"{oauth_settings.base_redirect_url}/yandex", - ) - - -def get_registry() -> SSORegistry: - return registry - - -RegistryDep = Annotated[SSORegistry, Depends(get_registry)] diff --git a/fastid/oauth/clients/registry.py b/fastid/oauth/clients/registry.py deleted file mode 100644 index 6f92ccb..0000000 --- a/fastid/oauth/clients/registry.py +++ /dev/null @@ -1,53 +0,0 @@ -from collections.abc import Callable, MutableMapping - -from fastlink import SSOBase - -from fastid.oauth.exceptions import OAuthProviderDisabledError, OAuthProviderNotFoundError -from fastid.oauth.schemas import SSOMeta, SSORegistryMeta - - -class SSORegistry: # pragma: nocover - def __init__( - self, - *, - base_authorization_url: str, - base_revoke_url: str, - ) -> None: - self.metadata = SSORegistryMeta() - self.base_authorization_url = base_authorization_url - self.base_revoke_url = base_revoke_url - self._providers: MutableMapping[str, Callable[[], SSOBase]] = {} - - def provider( - self, - name: str, - *, - title: str, - icon: str, - color: str, - enabled: bool = True, - ) -> Callable[[Callable[[], SSOBase]], Callable[[], SSOBase]]: - def wrapper( - factory: Callable[[], SSOBase], - ) -> Callable[[], SSOBase]: - meta = SSOMeta( - name=name, - title=title, - icon=icon, - color=color, - authorization_url=f"{self.base_authorization_url}/{name}", - revoke_url=f"{self.base_revoke_url}/{name}", - enabled=enabled, - ) - self.metadata.providers[name] = meta - self._providers[name] = factory - return factory - - return wrapper - - def get(self, name: str) -> SSOBase: - if name not in self.metadata.providers: - raise OAuthProviderNotFoundError - if not self.metadata.providers[name].enabled: - raise OAuthProviderDisabledError - return self._providers[name]() diff --git a/fastid/oauth/config.py b/fastid/oauth/config.py deleted file mode 100644 index 730df77..0000000 --- a/fastid/oauth/config.py +++ /dev/null @@ -1,46 +0,0 @@ -from pydantic_settings import SettingsConfigDict - -from fastid.core.config import main_settings -from fastid.core.schemas import BaseModel, BaseSettings - - -class OAuthSettings(BaseModel): - @property - def base_authorization_url(self) -> str: - return f"{main_settings.api_url}/oauth/login" - - @property - def base_redirect_url(self) -> str: - return f"{main_settings.api_url}/oauth/callback" - - @property - def base_revoke_url(self) -> str: - return f"{main_settings.api_url}/oauth/revoke" - - -class BaseOAuthSettings(BaseModel): - enabled: bool = False - client_id: str = "" - client_secret: str = "" - - -class GoogleSettings(BaseSettings, BaseOAuthSettings): - model_config = SettingsConfigDict(env_prefix="google_") - - -class YandexSettings(BaseSettings, BaseOAuthSettings): - model_config = SettingsConfigDict(env_prefix="yandex_") - - -class TelegramSettings(BaseSettings): - oauth_enabled: bool = False - notification_enabled: bool = False - bot_token: str = "" - - model_config = SettingsConfigDict(env_prefix="telegram_") - - -oauth_settings = OAuthSettings() -google_settings = GoogleSettings() -yandex_settings = YandexSettings() -telegram_settings = TelegramSettings() diff --git a/fastid/oauth/metadata.py b/fastid/oauth/metadata.py new file mode 100644 index 0000000..0bbe521 --- /dev/null +++ b/fastid/oauth/metadata.py @@ -0,0 +1,37 @@ +from fastid.integrations.config import ( + integration_settings, +) +from fastid.oauth.schemas import UIProviderMeta, UIProviderMetaEntry + +UI_META = UIProviderMeta() + +BASE_AUTHORIZATION_URL = integration_settings.base_authorization_url +BASE_REVOKE_URL = integration_settings.base_revoke_url + +UI_META.providers["google"] = UIProviderMetaEntry( + name="google", + title="Google", + icon="fa-google", + color="#F44336", + authorization_url=f"{BASE_AUTHORIZATION_URL}/google", + revoke_url=f"{BASE_REVOKE_URL}/google", + enabled=integration_settings.google_oauth_enabled, +) +UI_META.providers["telegram"] = UIProviderMetaEntry( + name="telegram", + title="Telegram", + icon="fa-telegram", + color="#03A9F4", + authorization_url=f"{BASE_AUTHORIZATION_URL}/telegram", + revoke_url=f"{BASE_REVOKE_URL}/telegram", + enabled=integration_settings.telegram_widget_enabled, +) +UI_META.providers["yandex"] = UIProviderMetaEntry( + name="yandex", + title="Yandex", + icon="fa-yandex", + color="#EA4335", + authorization_url=f"{BASE_AUTHORIZATION_URL}/yandex", + revoke_url=f"{BASE_REVOKE_URL}/yandex", + enabled=integration_settings.yandex_oauth_enabled, +) diff --git a/fastid/oauth/router.py b/fastid/oauth/router.py index 4e4c13c..5d1fa0a 100644 --- a/fastid/oauth/router.py +++ b/fastid/oauth/router.py @@ -2,18 +2,17 @@ from fastapi import APIRouter, Depends, Response from fastapi.responses import RedirectResponse -from fastlink import TelegramSSO -from fastlink.schemas import OAuth2Callback -from fastlink.telegram.schemas import TelegramCallback from starlette import status from starlette.requests import Request from starlette.responses import HTMLResponse from fastid.auth.config import auth_settings from fastid.auth.dependencies import UserDep, UserOrNoneDep, cookie_transport +from fastid.auth.schemas import OAuth2Callback from fastid.core.dependencies import log from fastid.database.schemas import PageDTO -from fastid.oauth.clients.dependencies import get_telegram_sso +from fastid.integrations.dependencies import TelegramWidgetDep +from fastid.integrations.schemas import TelegramCallback from fastid.oauth.dependencies import OAuthAccountsDep from fastid.oauth.schemas import InspectProviderResponse, OAuthAccountDTO @@ -106,9 +105,9 @@ async def oauth_revoke( "/redirect/telegram", status_code=status.HTTP_200_OK, ) -async def telegram_redirect(sso: Annotated[TelegramSSO, Depends(get_telegram_sso)]) -> Any: - async with sso: - content = await sso.widget() +async def telegram_redirect(widget: TelegramWidgetDep) -> Any: + async with widget: + content = await widget.widget_html() return HTMLResponse(content=content) diff --git a/fastid/oauth/schemas.py b/fastid/oauth/schemas.py index 565813b..329d92f 100644 --- a/fastid/oauth/schemas.py +++ b/fastid/oauth/schemas.py @@ -1,8 +1,8 @@ from collections.abc import MutableMapping, Sequence -from fastlink.schemas import DiscoveryDocument, OpenID, ProviderMeta, TokenResponse from pydantic import Field +from fastid.auth.schemas import DiscoveryDocument, OpenID, ProviderMeta, TokenResponse from fastid.core.schemas import BaseModel from fastid.database.schemas import EntityDTO from fastid.database.utils import UUIDv7 @@ -40,7 +40,7 @@ class InspectProviderResponse(BaseModel): login_url: str -class SSOMeta(BaseModel): +class UIProviderMetaEntry(BaseModel): name: str title: str icon: str @@ -50,11 +50,11 @@ class SSOMeta(BaseModel): enabled: bool = True -class SSORegistryMeta(BaseModel): - providers: MutableMapping[str, SSOMeta] = Field(default_factory=dict) +class UIProviderMeta(BaseModel): + providers: MutableMapping[str, UIProviderMetaEntry] = Field(default_factory=dict) @property - def enabled_providers(self) -> Sequence[SSOMeta]: + def enabled_providers(self) -> Sequence[UIProviderMetaEntry]: return list(self.providers.values()) @property diff --git a/fastid/oauth/use_cases.py b/fastid/oauth/use_cases.py index 6af7eba..88b676f 100644 --- a/fastid/oauth/use_cases.py +++ b/fastid/oauth/use_cases.py @@ -1,17 +1,19 @@ import contextlib - -from fastlink.schemas import OAuth2Callback, TokenResponse -from fastlink.telegram.schemas import TelegramCallback +from typing import Any, cast from fastid.auth.models import User from fastid.auth.repositories import EmailUserSpecification +from fastid.auth.schemas import OAuth2Callback, TokenResponse from fastid.core.base import UseCase from fastid.database.dependencies import UOWDep from fastid.database.exceptions import NoResultFoundError from fastid.database.schemas import LimitOffset, Page, Sorting -from fastid.oauth.clients.dependencies import RegistryDep +from fastid.integrations.config import integration_settings +from fastid.integrations.dependencies import OAuth2RegistryDep, TelegramWidgetDep +from fastid.integrations.schemas import TelegramCallback from fastid.oauth.exceptions import ( OAuthAccountInUseError, + OAuthProviderDisabledError, ) from fastid.oauth.models import OAuthAccount from fastid.oauth.repositories import ( @@ -25,23 +27,24 @@ class OAuthUseCases(UseCase): - def __init__(self, uow: UOWDep, registry: RegistryDep) -> None: + def __init__(self, uow: UOWDep, registry: OAuth2RegistryDep, telegram_widget: TelegramWidgetDep) -> None: self.uow = uow self.registry = registry + self.telegram_widget = telegram_widget + + async def get_login_url(self, provider: str) -> str: + async with self._get_client(provider) as client: + return cast(str, await client.login_url()) async def inspect(self, provider: str) -> InspectProviderResponse: - async with self.registry.get(provider) as session: - login_url = await session.login_url() + async with self._get_client(provider) as client: + login_url = await client.login_url() return InspectProviderResponse( - meta=session.meta, - discovery=session.discovery, + meta=client.meta, + discovery=client.discovery, login_url=login_url, ) - async def get_login_url(self, provider: str) -> str: - async with self.registry.get(provider) as session: - return await session.login_url() - async def login(self, provider: str, callback: OAuth2Callback | TelegramCallback) -> TokenResponse: open_id = await self._callback(provider, callback) try: @@ -88,6 +91,13 @@ async def revoke(self, user: User, provider: str) -> OAuthAccount: await self.uow.commit() return account + def _get_client(self, provider: str) -> Any: + if provider == "telegram": + if not integration_settings.telegram_widget_enabled: + raise OAuthProviderDisabledError + return self.telegram_widget + return self.registry.get(provider) + async def _callback(self, provider: str, callback: OAuth2Callback | TelegramCallback) -> OpenIDBearer: if provider == "telegram": assert isinstance(callback, TelegramCallback) @@ -96,20 +106,20 @@ async def _callback(self, provider: str, callback: OAuth2Callback | TelegramCall return await self._oauth2_callback(provider, callback) async def _oauth2_callback(self, provider: str, callback: OAuth2Callback) -> OpenIDBearer: - async with self.registry.get(provider) as session: - token = await session.login(callback) - openid = await session.openid() + async with self._get_client(provider) as client: + token = await client.login(callback) + userinfo = await client.userinfo() return OpenIDBearer( - **openid.model_dump(), - **token.model_dump(), + **userinfo.userinfo.model_dump(), + **token.token.model_dump(), provider=provider, ) async def _telegram_callback(self, callback: TelegramCallback) -> OpenIDBearer: - async with self.registry.get("telegram") as session: - openid = await session.callback(callback) # type: ignore[arg-type] + async with self._get_client("telegram") as client: + userinfo = await client.verify(callback) return OpenIDBearer( - **openid.model_dump(), + **userinfo.userinfo.model_dump(), provider="telegram", ) diff --git a/fastid/plugins/obs/config.py b/fastid/plugins/obs/config.py deleted file mode 100644 index 6abec8d..0000000 --- a/fastid/plugins/obs/config.py +++ /dev/null @@ -1,13 +0,0 @@ -from pydantic_settings import SettingsConfigDict - -from fastid.core.schemas import BaseSettings - - -class ObsSettings(BaseSettings): - enabled: bool = False - tempo_url: str = "http://host.docker.internal:4317" - - model_config = SettingsConfigDict(env_prefix="obs_") - - -obs_settings = ObsSettings() diff --git a/fastid/plugins/observability/__init__.py b/fastid/plugins/observability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastid/plugins/observability/config.py b/fastid/plugins/observability/config.py new file mode 100644 index 0000000..5aa4288 --- /dev/null +++ b/fastid/plugins/observability/config.py @@ -0,0 +1,10 @@ +from fastid.core.schemas import BaseSettings + + +class ObservabilitySettings(BaseSettings): + metrics_enabled: bool = False + tracing_enabled: bool = False + tempo_url: str = "http://host.docker.internal:4317" + + +observability_settings = ObservabilitySettings() diff --git a/fastid/plugins/obs/metrics.py b/fastid/plugins/observability/metrics.py similarity index 94% rename from fastid/plugins/obs/metrics.py rename to fastid/plugins/observability/metrics.py index ede35b6..e8b02b9 100644 --- a/fastid/plugins/obs/metrics.py +++ b/fastid/plugins/observability/metrics.py @@ -10,7 +10,7 @@ from starlette.responses import Response from fastid.core.base import Plugin -from fastid.plugins.obs.prometheus import PrometheusMiddleware +from fastid.plugins.observability.prometheus import PrometheusMiddleware class EndpointFilter(logging.Filter): diff --git a/fastid/plugins/obs/panels.py b/fastid/plugins/observability/panels.py similarity index 100% rename from fastid/plugins/obs/panels.py rename to fastid/plugins/observability/panels.py diff --git a/fastid/plugins/obs/prometheus.py b/fastid/plugins/observability/prometheus.py similarity index 98% rename from fastid/plugins/obs/prometheus.py rename to fastid/plugins/observability/prometheus.py index bf01d41..6cd13ae 100644 --- a/fastid/plugins/obs/prometheus.py +++ b/fastid/plugins/observability/prometheus.py @@ -10,7 +10,7 @@ from starlette.types import ASGIApp from fastid.api.exceptions import unhandled_exception_handler -from fastid.plugins.obs import panels +from fastid.plugins.observability import panels class PrometheusMiddleware(BaseHTTPMiddleware): diff --git a/fastid/plugins/obs/tracing.py b/fastid/plugins/observability/tracing.py similarity index 100% rename from fastid/plugins/obs/tracing.py rename to fastid/plugins/observability/tracing.py diff --git a/fastid/security/jwt.py b/fastid/security/jwt.py index f54398a..99512bb 100644 --- a/fastid/security/jwt.py +++ b/fastid/security/jwt.py @@ -1,14 +1,14 @@ import datetime from fastid.auth.config import auth_settings -from fastid.core.config import main_settings +from fastid.core.config import core_settings from fastid.security.manager import JWTManager from fastid.security.schemas import JWTConfig conf = [ JWTConfig( type="access", - issuer=main_settings.base_url, + issuer=core_settings.frontend_url, algorithm="RS256", private_key=auth_settings.jwt_private_key.read_text(), public_key=auth_settings.jwt_public_key.read_text(), @@ -16,7 +16,7 @@ ), JWTConfig( type="refresh", - issuer=main_settings.base_url, + issuer=core_settings.frontend_url, algorithm="RS256", private_key=auth_settings.jwt_private_key.read_text(), public_key=auth_settings.jwt_public_key.read_text(), @@ -24,7 +24,7 @@ ), JWTConfig( type="verify", - issuer=main_settings.base_url, + issuer=core_settings.frontend_url, algorithm="RS256", private_key=auth_settings.jwt_private_key.read_text(), public_key=auth_settings.jwt_public_key.read_text(), @@ -32,7 +32,7 @@ ), JWTConfig( type="id", - issuer=main_settings.base_url, + issuer=core_settings.frontend_url, algorithm="RS256", private_key=auth_settings.jwt_private_key.read_text(), public_key=auth_settings.jwt_public_key.read_text(), diff --git a/fastid/security/transport.py b/fastid/security/transport.py new file mode 100644 index 0000000..1e5ec60 --- /dev/null +++ b/fastid/security/transport.py @@ -0,0 +1,110 @@ +import abc +from abc import ABC +from typing import Literal + +from fastapi import HTTPException, Request, Response +from fastapi.security.utils import get_authorization_scheme_param +from starlette.responses import JSONResponse + +from fastid.auth.schemas import TokenResponse + + +class Transport(ABC): + def __init__(self, *, name: str, scheme_name: str) -> None: + self.name = name + self.scheme_name = scheme_name + + @abc.abstractmethod + def get_token(self, request: Request) -> str | None: ... + + @abc.abstractmethod + def set_token(self, response: Response, token: str) -> Response: ... + + @abc.abstractmethod + def delete_token(self, response: Response) -> Response: ... + + def get_login_response(self, token: TokenResponse) -> Response: + response = JSONResponse(content=token.model_dump()) + assert token.access_token is not None + self.set_token(response, token.access_token) + return response + + def get_logout_response(self) -> Response: + response = Response() + self.delete_token(response) + return response + + def __call__(self, request: Request) -> str: + token = self.get_token(request) + if token is None: + raise HTTPException( + status_code=401, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return token + + +class HeaderTransport(Transport): + def __init__( + self, + *, + name: str = "Authorization", + scheme_name: str = "BearerHeader", + ) -> None: + super().__init__(name=name, scheme_name=scheme_name) + + def get_token(self, request: Request) -> str | None: + authorization = request.headers.get(self.name) + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + return None + return param + + def set_token(self, response: Response, token: str) -> Response: + response.headers[self.name] = f"Bearer {token}" + return response + + def delete_token(self, response: Response) -> Response: + del response.headers[self.name] + return response + + +class CookieTransport(Transport): + def __init__( # noqa: PLR0913 + self, + *, + name: str = "access_token", + scheme_name: str = "BearerCookie", + httponly: bool = True, + max_age: int = 3600, + secure: bool = False, + samesite: Literal["lax", "strict", "none"] = "lax", + ) -> None: + super().__init__(name=name, scheme_name=scheme_name) + self.httponly = httponly + self.max_age = max_age + self.secure = secure + self.samesite = samesite + + def get_token(self, request: Request) -> str | None: + return request.cookies.get(self.name) + + def set_token(self, response: Response, token: str) -> Response: + response.set_cookie( + key=self.name, + value=token, + httponly=self.httponly, + max_age=self.max_age, + secure=self.secure, + samesite=self.samesite, + ) + return response + + def delete_token(self, response: Response) -> Response: + response.delete_cookie( + key=self.name, + secure=self.secure, + samesite=self.samesite, + ) + return response diff --git a/fastid/webhooks/config.py b/fastid/webhooks/config.py index 9c8cef3..c49fa7e 100644 --- a/fastid/webhooks/config.py +++ b/fastid/webhooks/config.py @@ -1,6 +1,6 @@ from pydantic_settings import SettingsConfigDict -from fastid.core.schemas import BaseSettings +from fastid.core.schemas import ENV_PREFIX, BaseSettings from fastid.webhooks.schemas import SignatureAlgorithm @@ -10,8 +10,9 @@ class WebhookSettings(BaseSettings): timestamp_header: str = "X-Webhook-Timestamp" id_header: str = "X-Webhook-Id" tolerance_seconds: int = 300 + page_expires_in_seconds: int = 60 - model_config = SettingsConfigDict(env_prefix="webhook_") + model_config = SettingsConfigDict(env_prefix=f"{ENV_PREFIX}webhook_") webhook_settings = WebhookSettings() diff --git a/fastid/webhooks/models.py b/fastid/webhooks/models.py index 4554348..a6f5185 100644 --- a/fastid/webhooks/models.py +++ b/fastid/webhooks/models.py @@ -1,19 +1,20 @@ from __future__ import annotations -from enum import StrEnum, auto +from enum import auto from typing import TYPE_CHECKING, Any from uuid import UUID # noqa: TCH003 from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship +from fastid.core.schemas import BaseEnum from fastid.database.base import Entity, VersionedEntity if TYPE_CHECKING: from fastid.apps.models import App -class WebhookType(StrEnum): +class WebhookType(BaseEnum): user_registration = auto() user_login = auto() user_update_profile = auto() diff --git a/fastid/webhooks/schemas.py b/fastid/webhooks/schemas.py index a1fc880..91cf953 100644 --- a/fastid/webhooks/schemas.py +++ b/fastid/webhooks/schemas.py @@ -1,15 +1,15 @@ -from enum import StrEnum, auto +from enum import auto from typing import Any from uuid import UUID from pydantic import Field from fastid.auth.schemas import UserDTO -from fastid.core.schemas import BaseModel +from fastid.core.schemas import BaseEnum, BaseModel from fastid.webhooks.models import WebhookType -class SignatureAlgorithm(StrEnum): +class SignatureAlgorithm(BaseEnum): sha256 = auto() sha512 = auto() sha1 = auto() diff --git a/fastid/webhooks/use_cases.py b/fastid/webhooks/use_cases.py index 44e9d54..5a77f4d 100644 --- a/fastid/webhooks/use_cases.py +++ b/fastid/webhooks/use_cases.py @@ -8,6 +8,7 @@ get_timestamp, get_webhook_id, ) +from fastid.webhooks.config import webhook_settings from fastid.webhooks.models import Webhook, WebhookEvent from fastid.webhooks.repositories import WebhookTypeSpecification from fastid.webhooks.schemas import Event, SendWebhookRequest, WebhookPayload @@ -28,7 +29,11 @@ async def send(self, dto: SendWebhookRequest) -> None: except KeyNotFoundError: webhook_page = await self.uow.webhooks.get_many(WebhookTypeSpecification(dto.type)) webhooks = webhook_page.items - await self.cache.set(cache_key, [w.dump() for w in webhooks], expire=60) + await self.cache.set( + cache_key, + [{"id": str(w.id), "secret": w.secret, "url": w.url} for w in webhooks], + expire=webhook_settings.page_expires_in_seconds, + ) else: webhooks = [Webhook(**w) for w in cached] event_id = get_event_id() diff --git a/migrations/env.py b/migrations/env.py index 2a84469..06064c6 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -6,7 +6,7 @@ from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from fastid.core.config import Environment, main_settings +from fastid.core.config import Environment, core_settings from fastid.database.config import db_settings from fastid.database.models import BaseOrm @@ -16,7 +16,7 @@ # Interpret the config file for Python logging. # This line sets up loggers basically. -if config.config_file_name is not None and main_settings.env != Environment.test: +if config.config_file_name is not None and core_settings.env != Environment.test: fileConfig(config.config_file_name) # add your model's MetaData object here diff --git a/poetry.lock b/poetry.lock index 64c7312..7bb889e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1326,25 +1326,6 @@ uvicorn = {version = ">=0.15.0", extras = ["standard"]} [package.extras] standard = ["uvicorn[standard] (>=0.15.0)"] -[[package]] -name = "fastlink" -version = "0.1.6" -description = "FastLink OAuth 2.0 client for various platforms, asynchronous, easy-to-use, extensible" -optional = false -python-versions = "<4.0,>=3.12" -groups = ["main"] -files = [ - {file = "fastlink-0.1.6-py3-none-any.whl", hash = "sha256:77f1ceb8af24145f4dce43d24569b019305d5a76053702dc94ffdf84885fb239"}, - {file = "fastlink-0.1.6.tar.gz", hash = "sha256:0c95a19a1042acfaf4f01d0371fd22fc291bbe323f0e320eddf6c14170d66471"}, -] - -[package.dependencies] -aiogram = ">=3.20.0.post0,<4.0.0" -fastapi = {version = ">=0.115.7,<0.116.0", extras = ["standart"]} -httpx = ">=0.28.1,<0.29.0" -pydantic = ">=2.11.5,<3.0.0" -pyjwt = ">=2.10.1,<3.0.0" - [[package]] name = "filelock" version = "3.20.3" @@ -5094,4 +5075,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "fdf6fc122b5be5c3c943add4eb9c34b8f232a8b16d7e70af0140275ec303b825" +content-hash = "777988509ee50bb4a267c78d450b16924ccefb1d6fe0d5f191a54a43acaea770" diff --git a/pyproject.toml b/pyproject.toml index a0c207c..958631d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ opentelemetry-instrumentation-httpx = "^0.52b1" fast-depends = "^2.4.12" uuid-utils = "^0.9.0" typer = "^0.14.0" -fastlink = "^0.1.6" argon2-cffi = "^25.1.0" sqlalchemy-continuum = "^1.5.2" diff --git a/tests/api/apps/test_create_app.py b/tests/api/apps/test_create_app.py index ec62039..91efe81 100644 --- a/tests/api/apps/test_create_app.py +++ b/tests/api/apps/test_create_app.py @@ -1,8 +1,8 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.apps.schemas import AppDTO +from fastid.auth.schemas import TokenResponse from tests import mocks diff --git a/tests/api/apps/test_delete_app.py b/tests/api/apps/test_delete_app.py index 3444648..0436cb2 100644 --- a/tests/api/apps/test_delete_app.py +++ b/tests/api/apps/test_delete_app.py @@ -1,8 +1,8 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.apps.schemas import AppDTO +from fastid.auth.schemas import TokenResponse from fastid.database.utils import uuid diff --git a/tests/api/apps/test_get_app.py b/tests/api/apps/test_get_app.py index 4017a39..41481e7 100644 --- a/tests/api/apps/test_get_app.py +++ b/tests/api/apps/test_get_app.py @@ -1,8 +1,8 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.apps.schemas import AppDTO +from fastid.auth.schemas import TokenResponse from fastid.database.utils import uuid diff --git a/tests/api/apps/test_update_app.py b/tests/api/apps/test_update_app.py index ed07df5..3a742b8 100644 --- a/tests/api/apps/test_update_app.py +++ b/tests/api/apps/test_update_app.py @@ -1,8 +1,8 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.apps.schemas import AppDTO +from fastid.auth.schemas import TokenResponse from fastid.database.utils import uuid from tests import mocks diff --git a/tests/api/auth/test_authorize_authorization_code_grant.py b/tests/api/auth/test_authorize_authorization_code_grant.py index 8c6436a..91d2afe 100644 --- a/tests/api/auth/test_authorize_authorization_code_grant.py +++ b/tests/api/auth/test_authorize_authorization_code_grant.py @@ -1,8 +1,8 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.apps.schemas import AppDTO +from fastid.auth.schemas import TokenResponse from fastid.security.crypto import generate_otp from tests.utils.auth import authorize_authorization_code_grant, get_ac_grant_callback diff --git a/tests/api/auth/test_authorize_refresh_token_grant.py b/tests/api/auth/test_authorize_refresh_token_grant.py index 6852de4..9bdc38e 100644 --- a/tests/api/auth/test_authorize_refresh_token_grant.py +++ b/tests/api/auth/test_authorize_refresh_token_grant.py @@ -1,8 +1,8 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.apps.schemas import AppDTO +from fastid.auth.schemas import TokenResponse from fastid.database.utils import uuid from tests.mocks import faker diff --git a/tests/api/auth/test_callback_authorization_code_grant.py b/tests/api/auth/test_callback_authorization_code_grant.py index cb0586d..d85925d 100644 --- a/tests/api/auth/test_callback_authorization_code_grant.py +++ b/tests/api/auth/test_callback_authorization_code_grant.py @@ -1,9 +1,9 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.apps.schemas import AppDTO from fastid.auth.dependencies import cookie_transport +from fastid.auth.schemas import TokenResponse from tests import mocks from tests.mocks import faker from tests.utils.auth import get_ac_grant_callback diff --git a/tests/api/auth/test_logout.py b/tests/api/auth/test_logout.py index b77b95f..b68d5d9 100644 --- a/tests/api/auth/test_logout.py +++ b/tests/api/auth/test_logout.py @@ -1,8 +1,8 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.auth.dependencies import cookie_transport +from fastid.auth.schemas import TokenResponse async def test_logout(client: AsyncClient, user_token: TokenResponse) -> None: diff --git a/tests/api/auth/test_openid_configuration.py b/tests/api/auth/test_openid_configuration.py index f2eb5fe..2cd1e95 100644 --- a/tests/api/auth/test_openid_configuration.py +++ b/tests/api/auth/test_openid_configuration.py @@ -1,7 +1,8 @@ -from fastlink.schemas import JWKS, DiscoveryDocument from httpx import AsyncClient from starlette import status +from fastid.auth.schemas import JWKS, DiscoveryDocument + async def test_openid_configuration(frontend_client: AsyncClient) -> None: response = await frontend_client.get("/.well-known/openid-configuration") diff --git a/tests/api/auth/test_userinfo.py b/tests/api/auth/test_userinfo.py index 83d4c3e..4c77960 100644 --- a/tests/api/auth/test_userinfo.py +++ b/tests/api/auth/test_userinfo.py @@ -1,8 +1,7 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.utils import uuid_hex from fastid.security.jwt import jwt_backend from fastid.security.schemas import JWTPayload diff --git a/tests/api/conftest.py b/tests/api/conftest.py index ca1c611..75781e6 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -1,14 +1,13 @@ from typing import cast import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncEngine from starlette import status from fastid.apps.schemas import AppDTO -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.cache.storage import CacheStorage from fastid.core.dependencies import log_provider from fastid.core.lifespan import LifespanTasks diff --git a/tests/api/notify/test_notify_otp.py b/tests/api/notify/test_notify_otp.py index 8ce0b20..1b53ebc 100644 --- a/tests/api/notify/test_notify_otp.py +++ b/tests/api/notify/test_notify_otp.py @@ -1,9 +1,8 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.cache.storage import CacheStorage from fastid.notify.schemas import UserAction from tests.mocks import faker diff --git a/tests/api/notify/test_send_email.py b/tests/api/notify/test_send_email.py index a4a3a47..07b7100 100644 --- a/tests/api/notify/test_send_email.py +++ b/tests/api/notify/test_send_email.py @@ -1,9 +1,8 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.uow import SQLAlchemyUOW from tests import mocks diff --git a/tests/api/notify/test_send_telegram.py b/tests/api/notify/test_send_telegram.py index fd85b1b..f80daf6 100644 --- a/tests/api/notify/test_send_telegram.py +++ b/tests/api/notify/test_send_telegram.py @@ -1,8 +1,8 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status +from fastid.auth.schemas import TokenResponse from tests.mocks import PUSH_NOTIFICATION_REQUEST, PUSH_NOTIFICATION_REQUEST_FAKE_TEMPLATE diff --git a/tests/api/notify/test_verify_otp.py b/tests/api/notify/test_verify_otp.py index b4a045d..5084419 100644 --- a/tests/api/notify/test_verify_otp.py +++ b/tests/api/notify/test_verify_otp.py @@ -1,8 +1,7 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.cache.storage import CacheStorage from fastid.notify.schemas import UserAction from fastid.security.crypto import generate_otp diff --git a/tests/api/oauth/test_oauth_callback.py b/tests/api/oauth/test_oauth_callback.py index 4af4612..15059d1 100644 --- a/tests/api/oauth/test_oauth_callback.py +++ b/tests/api/oauth/test_oauth_callback.py @@ -1,22 +1,22 @@ from unittest.mock import AsyncMock, patch import pytest -from fastlink.schemas import OpenID, TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import OpenID, TokenResponse, UserDTO +from fastid.integrations.schemas import UserinfoResponse from tests import mocks @pytest.mark.parametrize(("provider", "openid"), [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID)]) async def test_oauth_callback_authorize(client: AsyncClient, provider: str, openid: OpenID) -> None: params = mocks.OAUTH_CALLBACK.model_dump(mode="json", exclude_unset=True) - authorize_mock = AsyncMock(return_value=mocks.OAUTH_TOKEN_RESPONSE) + authorize_mock = AsyncMock(return_value=mocks.LOGIN_RESPONSE) userinfo_mock = AsyncMock(return_value=openid) with ( - patch("fastlink.SSOBase.login", new=authorize_mock), - patch("fastlink.SSOBase.openid", new=userinfo_mock), + patch("fastid.integrations.base.oauth.OAuth2Client.login", new=authorize_mock), + patch("fastid.integrations.base.oauth.OAuth2Client.userinfo", new=userinfo_mock), ): response = await client.get(f"/oauth/callback/{provider}", params=params) assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT @@ -27,16 +27,16 @@ async def test_oauth_callback_authorize_email_exists( client: AsyncClient, user: UserDTO, provider: str, - openid: OpenID, + openid: UserinfoResponse, ) -> None: - openid.email = user.email + openid.userinfo.email = user.email params = mocks.OAUTH_CALLBACK.model_dump(mode="json", exclude_unset=True) - authorize_mock = AsyncMock(return_value=mocks.OAUTH_TOKEN_RESPONSE) + authorize_mock = AsyncMock(return_value=mocks.LOGIN_RESPONSE) userinfo_mock = AsyncMock(return_value=openid) with ( - patch("fastlink.SSOBase.login", new=authorize_mock), - patch("fastlink.SSOBase.openid", new=userinfo_mock), + patch("fastid.integrations.base.oauth.OAuth2Client.login", new=authorize_mock), + patch("fastid.integrations.base.oauth.OAuth2Client.userinfo", new=userinfo_mock), ): response = await client.get(f"/oauth/callback/{provider}", params=params) assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT @@ -50,11 +50,11 @@ async def test_oauth_callback_connect( openid: OpenID, ) -> None: params = mocks.OAUTH_CALLBACK.model_dump(mode="json", exclude_unset=True) - authorize_mock = AsyncMock(return_value=mocks.OAUTH_TOKEN_RESPONSE) + authorize_mock = AsyncMock(return_value=mocks.LOGIN_RESPONSE) userinfo_mock = AsyncMock(return_value=openid) with ( - patch("fastlink.SSOBase.login", new=authorize_mock), - patch("fastlink.SSOBase.openid", new=userinfo_mock), + patch("fastid.integrations.base.oauth.OAuth2Client.login", new=authorize_mock), + patch("fastid.integrations.base.oauth.OAuth2Client.userinfo", new=userinfo_mock), ): response = await client.get( f"/oauth/callback/{provider}", @@ -72,11 +72,11 @@ async def test_oauth_callback_double_connect( openid: OpenID, ) -> None: params = mocks.OAUTH_CALLBACK.model_dump(mode="json", exclude_unset=True) - authorize_mock = AsyncMock(return_value=mocks.OAUTH_TOKEN_RESPONSE) + authorize_mock = AsyncMock(return_value=mocks.LOGIN_RESPONSE) userinfo_mock = AsyncMock(return_value=openid) with ( - patch("fastlink.SSOBase.login", new=authorize_mock), - patch("fastlink.SSOBase.openid", new=userinfo_mock), + patch("fastid.integrations.base.oauth.OAuth2Client.login", new=authorize_mock), + patch("fastid.integrations.base.oauth.OAuth2Client.userinfo", new=userinfo_mock), ): response = await client.get( f"/oauth/callback/{provider}", @@ -95,11 +95,9 @@ async def test_oauth_callback_double_connect( async def test_telegram_callback_register(client: AsyncClient) -> None: params = mocks.TELEGRAM_CALLBACK.model_dump(mode="json", exclude_unset=True) - authorize_mock = AsyncMock(return_value=mocks.OAUTH_TOKEN_RESPONSE) userinfo_mock = AsyncMock(return_value=mocks.TELEGRAM_OPENID) with ( - patch("fastlink.TelegramSSO.login", new=authorize_mock), - patch("fastlink.TelegramSSO.openid", new=userinfo_mock), + patch("fastid.integrations.telegram.login.TelegramLoginWidget.verify", new=userinfo_mock), ): response = await client.get( "/oauth/callback/telegram", @@ -110,11 +108,9 @@ async def test_telegram_callback_register(client: AsyncClient) -> None: async def test_telegram_callback_connect(client: AsyncClient, user_token: TokenResponse) -> None: params = mocks.TELEGRAM_CALLBACK.model_dump(mode="json", exclude_unset=True) - authorize_mock = AsyncMock(return_value=mocks.OAUTH_TOKEN_RESPONSE) userinfo_mock = AsyncMock(return_value=mocks.TELEGRAM_OPENID) with ( - patch("fastlink.TelegramSSO.login", new=authorize_mock), - patch("fastlink.TelegramSSO.openid", new=userinfo_mock), + patch("fastid.integrations.telegram.login.TelegramLoginWidget.verify", new=userinfo_mock), ): response = await client.get( "/oauth/callback/telegram", diff --git a/tests/api/oauth/test_oauth_get_many.py b/tests/api/oauth/test_oauth_get_many.py index 75046ee..60744ed 100644 --- a/tests/api/oauth/test_oauth_get_many.py +++ b/tests/api/oauth/test_oauth_get_many.py @@ -1,9 +1,8 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.schemas import PageDTO from fastid.database.uow import SQLAlchemyUOW from fastid.oauth.models import OAuthAccount diff --git a/tests/api/oauth/test_oauth_inspect.py b/tests/api/oauth/test_oauth_inspect.py index 8a43757..8782b20 100644 --- a/tests/api/oauth/test_oauth_inspect.py +++ b/tests/api/oauth/test_oauth_inspect.py @@ -1,8 +1,8 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status +from fastid.auth.schemas import TokenResponse from fastid.oauth.schemas import InspectProviderResponse diff --git a/tests/api/oauth/test_oauth_login.py b/tests/api/oauth/test_oauth_login.py index f1a9624..1fa6f21 100644 --- a/tests/api/oauth/test_oauth_login.py +++ b/tests/api/oauth/test_oauth_login.py @@ -1,8 +1,9 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status +from fastid.auth.schemas import TokenResponse + @pytest.mark.parametrize("provider", ["google", "yandex", "telegram"]) async def test_oauth_login(client: AsyncClient, user_token: TokenResponse, provider: str) -> None: diff --git a/tests/api/oauth/test_oauth_revoke.py b/tests/api/oauth/test_oauth_revoke.py index fc26ec5..37c8399 100644 --- a/tests/api/oauth/test_oauth_revoke.py +++ b/tests/api/oauth/test_oauth_revoke.py @@ -1,9 +1,8 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.uow import SQLAlchemyUOW from fastid.oauth.models import OAuthAccount from fastid.oauth.schemas import OpenIDBearer diff --git a/tests/api/oauth/test_telegram_redirect.py b/tests/api/oauth/test_telegram_redirect.py index 1552ab3..8d156c5 100644 --- a/tests/api/oauth/test_telegram_redirect.py +++ b/tests/api/oauth/test_telegram_redirect.py @@ -1,16 +1,16 @@ from unittest.mock import AsyncMock, patch -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status +from fastid.auth.schemas import TokenResponse from tests import mocks async def test_telegram_redirect(client: AsyncClient, user_token: TokenResponse) -> None: widget_mock = AsyncMock(return_value=mocks.TELEGRAM_WIDGET) with ( - patch("fastlink.TelegramSSO.widget_info", new=widget_mock), + patch("fastid.integrations.telegram.login.TelegramLoginWidget.widget_data", new=widget_mock), ): response = await client.get( "/oauth/redirect/telegram", diff --git a/tests/api/profile/test_delete_user.py b/tests/api/profile/test_delete_user.py index 18c1fdb..00367fa 100644 --- a/tests/api/profile/test_delete_user.py +++ b/tests/api/profile/test_delete_user.py @@ -1,10 +1,9 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.auth.dependencies import vt_transport -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.exceptions import NoResultFoundError from fastid.database.uow import SQLAlchemyUOW from fastid.webhooks.models import Webhook diff --git a/tests/api/profile/test_update_user_email.py b/tests/api/profile/test_update_user_email.py index 3ee5915..64d1645 100644 --- a/tests/api/profile/test_update_user_email.py +++ b/tests/api/profile/test_update_user_email.py @@ -1,10 +1,9 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.auth.dependencies import vt_transport -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.cache.storage import CacheStorage from fastid.database.exceptions import NoResultFoundError from fastid.database.uow import SQLAlchemyUOW diff --git a/tests/api/profile/test_update_user_password.py b/tests/api/profile/test_update_user_password.py index 9f5df32..922770a 100644 --- a/tests/api/profile/test_update_user_password.py +++ b/tests/api/profile/test_update_user_password.py @@ -1,10 +1,9 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status from fastid.auth.dependencies import vt_transport -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.exceptions import NoResultFoundError from fastid.database.uow import SQLAlchemyUOW from fastid.webhooks.models import Webhook diff --git a/tests/api/profile/test_update_user_profile.py b/tests/api/profile/test_update_user_profile.py index 32f22de..47fa3e2 100644 --- a/tests/api/profile/test_update_user_profile.py +++ b/tests/api/profile/test_update_user_profile.py @@ -1,9 +1,8 @@ import pytest -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.exceptions import NoResultFoundError from fastid.database.uow import SQLAlchemyUOW from fastid.webhooks.models import Webhook diff --git a/tests/api/superuser/test_delete_user.py b/tests/api/superuser/test_delete_user.py index 48b1138..fc25d30 100644 --- a/tests/api/superuser/test_delete_user.py +++ b/tests/api/superuser/test_delete_user.py @@ -1,8 +1,7 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.utils import uuid diff --git a/tests/api/superuser/test_get_user.py b/tests/api/superuser/test_get_user.py index e3d4ed4..c6f538f 100644 --- a/tests/api/superuser/test_get_user.py +++ b/tests/api/superuser/test_get_user.py @@ -1,8 +1,7 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.utils import uuid diff --git a/tests/api/superuser/test_get_users.py b/tests/api/superuser/test_get_users.py index 998af99..0ba6eec 100644 --- a/tests/api/superuser/test_get_users.py +++ b/tests/api/superuser/test_get_users.py @@ -1,8 +1,7 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.schemas import PageDTO diff --git a/tests/api/superuser/test_grant_su.py b/tests/api/superuser/test_grant_su.py index 39f9626..8955990 100644 --- a/tests/api/superuser/test_grant_su.py +++ b/tests/api/superuser/test_grant_su.py @@ -1,8 +1,7 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.utils import uuid diff --git a/tests/api/superuser/test_revoke_su.py b/tests/api/superuser/test_revoke_su.py index ad8b1f3..58766d4 100644 --- a/tests/api/superuser/test_revoke_su.py +++ b/tests/api/superuser/test_revoke_su.py @@ -1,8 +1,7 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.utils import uuid diff --git a/tests/api/superuser/test_update_user.py b/tests/api/superuser/test_update_user.py index 92d3fb4..c24c39c 100644 --- a/tests/api/superuser/test_update_user.py +++ b/tests/api/superuser/test_update_user.py @@ -1,8 +1,7 @@ -from fastlink.schemas import TokenResponse from httpx import AsyncClient from starlette import status -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import TokenResponse, UserDTO from fastid.database.utils import uuid from tests import mocks diff --git a/tests/conftest.py b/tests/conftest.py index f02555a..4efddb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,14 +9,15 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker from fastid.api.app import api_app -from fastid.cache.config import cache_settings +from fastid.cache.config import redis_settings from fastid.cache.dependencies import get_cache from fastid.cache.storage import CacheStorage, RedisStorage from fastid.core.dependencies import log_provider from fastid.database.dependencies import get_uow_raw from fastid.database.uow import SQLAlchemyUOW +from fastid.email.dependencies import get_smtp from fastid.frontend.app import frontend_app -from fastid.notify.clients.dependencies import get_bot, get_smtp +from fastid.integrations.dependencies import get_bot from tests.dependencies import ( alembic_config, get_test_cache, @@ -126,12 +127,12 @@ async def uow(uow_raw: SQLAlchemyUOW, engine: AsyncEngine) -> AsyncIterator[SQLA @pytest.fixture async def redis_client() -> AsyncIterator[Redis]: - logger.info("Test redis URL: %s", cache_settings.redis_url) + logger.info("Test redis URL: %s", redis_settings.url) yield test_redis await test_redis.aclose(close_connection_pool=True) @pytest.fixture async def cache(redis_client: Redis) -> AsyncIterator[CacheStorage]: - yield RedisStorage(redis_client, key=cache_settings.redis_key) + yield RedisStorage(redis_client, key=redis_settings.major_key) await test_redis.flushdb() diff --git a/tests/dependencies.py b/tests/dependencies.py index 4ed31e1..6c6f859 100644 --- a/tests/dependencies.py +++ b/tests/dependencies.py @@ -2,7 +2,7 @@ from sqlalchemy import pool from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from fastid.cache.config import cache_settings +from fastid.cache.config import redis_settings from fastid.cache.storage import CacheStorage, RedisStorage from fastid.core.dependencies import log_provider from fastid.database.config import db_settings @@ -16,7 +16,7 @@ db_settings.url = test_db_url alembic_config = alembic_config_from_url(test_db_url) -test_redis_pool = ConnectionPool.from_url(cache_settings.redis_url) +test_redis_pool = ConnectionPool.from_url(redis_settings.url) test_redis = Redis(connection_pool=test_redis_pool) test_engine = create_async_engine(test_db_url, poolclass=pool.NullPool) @@ -28,4 +28,4 @@ def get_test_uow() -> SQLAlchemyUOW: def get_test_cache() -> CacheStorage: - return RedisStorage(test_redis, key=cache_settings.redis_key) + return RedisStorage(test_redis, key=redis_settings.major_key) diff --git a/tests/mocks.py b/tests/mocks.py index 89c1229..b9bcc70 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -1,14 +1,13 @@ from typing import Any from faker import Faker -from fastlink.schemas import OAuth2Callback, OpenID, TokenResponse -from fastlink.telegram.schemas import TelegramCallback, TelegramWidget -from fastlink.telegram.utils import compute_hmac_sha256 from fastid.apps.schemas import AppCreate, AppUpdate -from fastid.auth.schemas import UserCreate, UserUpdate +from fastid.auth.schemas import OAuth2Callback, OpenID, TokenResponse, UserCreate, UserUpdate +from fastid.integrations.config import integration_settings +from fastid.integrations.schemas import LoginResponse, TelegramCallback, TelegramWidget, UserinfoResponse +from fastid.integrations.utils import compute_hmac_sha256 from fastid.notify.schemas import PushNotificationRequest -from fastid.oauth.config import telegram_settings from fastid.oauth.schemas import OpenIDBearer from fastid.security.crypto import crypt_ctx from fastid.security.webhooks import get_timestamp, get_webhook_id @@ -68,45 +67,51 @@ def user_record_factory(base: UserCreate, **kwargs: Any) -> dict[str, Any]: "picture": faker.image_url(), "auth_date": int(faker.date_time().timestamp()), } -TELEGRAM_CALLBACK_PAYLOAD["hash"] = compute_hmac_sha256(TELEGRAM_CALLBACK_PAYLOAD, telegram_settings.bot_token) +TELEGRAM_CALLBACK_PAYLOAD["hash"] = compute_hmac_sha256( + TELEGRAM_CALLBACK_PAYLOAD, integration_settings.telegram_bot_token +) TELEGRAM_CALLBACK = TelegramCallback(**TELEGRAM_CALLBACK_PAYLOAD) -OAUTH_TOKEN_RESPONSE = TokenResponse( +TOKEN_RESPONSE = TokenResponse( access_token=faker.pystr(min_chars=8, max_chars=256), expires_in=3600, refresh_token=faker.pystr(min_chars=8, max_chars=256), scope="openid email profile", ) - - -def openid_factory() -> OpenID: - return OpenID( - id=str(faker.random_number(digits=10)), - first_name=faker.first_name(), - last_name=faker.last_name(), - email=faker.email(), - picture=faker.image_url(), +LOGIN_RESPONSE = LoginResponse(token=TOKEN_RESPONSE, token_raw={}) + + +def userinfo_response_factory() -> UserinfoResponse: + return UserinfoResponse( + userinfo=OpenID( + id=str(faker.random_number(digits=10)), + first_name=faker.first_name(), + last_name=faker.last_name(), + email=faker.email(), + picture=faker.image_url(), + ), + userinfo_raw={}, ) -GOOGLE_OPENID = openid_factory() -YANDEX_OPENID = openid_factory() -TELEGRAM_OPENID = openid_factory() -TELEGRAM_OPENID.email = None +GOOGLE_OPENID = userinfo_response_factory() +YANDEX_OPENID = userinfo_response_factory() +TELEGRAM_OPENID = userinfo_response_factory() +TELEGRAM_OPENID.userinfo.email = None GOOGLE_OPENID_BEARER = OpenIDBearer( provider="google", - **GOOGLE_OPENID.model_dump(), - **OAUTH_TOKEN_RESPONSE.model_dump(), + **GOOGLE_OPENID.userinfo.model_dump(), + **TOKEN_RESPONSE.model_dump(), ) YANDEX_OPENID_BEARER = OpenIDBearer( provider="yandex", - **YANDEX_OPENID.model_dump(), - **OAUTH_TOKEN_RESPONSE.model_dump(), + **YANDEX_OPENID.userinfo.model_dump(), + **TOKEN_RESPONSE.model_dump(), ) TELEGRAM_OPENID_BEARER = OpenIDBearer( provider="telegram", - **TELEGRAM_OPENID.model_dump(), - **OAUTH_TOKEN_RESPONSE.model_dump(), + **TELEGRAM_OPENID.userinfo.model_dump(), + **TOKEN_RESPONSE.model_dump(), ) TELEGRAM_WIDGET = TelegramWidget(bot_username=faker.user_name(), callback_url=faker.url()) @@ -132,7 +137,6 @@ def webhook_record_factory(webhook_type: WebhookType, **kwargs: Any) -> dict[str WEBHOOK_WRONG_URL = webhook_record_factory(WebhookType.user_registration) WEBHOOK_WRONG_URL["url"] = "wrong url" - WEBHOOK_PAYLOAD = {"test": {"test1": 1, "test2": "hello", "hello3": True}} WEBHOOK_TIMESTAMP = get_timestamp() WEBHOOK_ID = str(get_webhook_id()) diff --git a/tests/utils/auth.py b/tests/utils/auth.py index 9046a76..bf681fe 100644 --- a/tests/utils/auth.py +++ b/tests/utils/auth.py @@ -3,14 +3,13 @@ import urllib.parse from typing import Any -from fastlink.schemas import OAuth2Callback, TokenResponse from httpx import AsyncClient from starlette import status from fastid.apps.schemas import AppDTO from fastid.auth.dependencies import cookie_transport from fastid.auth.models import User -from fastid.auth.schemas import UserDTO +from fastid.auth.schemas import OAuth2Callback, TokenResponse, UserDTO from fastid.database.uow import SQLAlchemyUOW from tests import mocks From 0e8eb03e970e724fe8a931c93399fbad2d734ca9 Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Sat, 6 Jun 2026 12:57:55 +0300 Subject: [PATCH 03/12] feat: simplify APISettings by removing unused imports and model config --- fastid/api/config.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/fastid/api/config.py b/fastid/api/config.py index 020ea72..94f849a 100644 --- a/fastid/api/config.py +++ b/fastid/api/config.py @@ -1,15 +1,11 @@ from collections.abc import Sequence -from pydantic_settings import SettingsConfigDict - -from fastid.core.schemas import ENV_PREFIX, BaseSettings +from fastid.core.schemas import BaseSettings class APISettings(BaseSettings): cors_origins: Sequence[str] = ("*",) cors_origin_regex: str | None = None - model_config = SettingsConfigDict(env_prefix=f"{ENV_PREFIX}api_") - api_settings = APISettings() From f73cf1f46f36e36520c72580241cfd2da4d13ffb Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Sat, 6 Jun 2026 13:04:25 +0300 Subject: [PATCH 04/12] feat: update test configuration for FASTID integration --- .github/workflows/test.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62cb2cc..228dd75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,14 +68,15 @@ jobs: run: | poetry run make testcov env: - GOOGLE_ENABLED: 1 - YANDEX_ENABLED: 1 - TELEGRAM_OAUTH_ENABLED: 1 - NOTIFY_SMTP_ENABLED: 1 - NOTIFY_TELEGRAM_ENABLED: 1 - TELEGRAM_BOT_TOKEN: 123456:BOT_SECRET - DB_URL: postgresql+asyncpg://${{ matrix.database-user }}:${{ matrix.database-password }}@${{ matrix.database-host }}:${{ matrix.database-port }}/${{ matrix.database-name }} - REDIS_URL: redis://${{ matrix.redis-user }}:${{ matrix.redis-password }}@${{ matrix.redis-host }}:${{ matrix.redis-port }} + FASTID_DB_URL: postgresql+asyncpg://${{ matrix.database-user }}:${{ matrix.database-password }}@${{ matrix.database-host }}:${{ matrix.database-port }}/${{ matrix.database-name }} + FASTID_REDIS_URL: redis://${{ matrix.redis-user }}:${{ matrix.redis-password }}@${{ matrix.redis-host }}:${{ matrix.redis-port }} + FASTID_GOOGLE_OAUTH_ENABLED: 1 + FASTID_YANDEX_OAUTH_ENABLED: 1 + FASTID_SMTP_ENABLED: 1 + FASTID_TELEGRAM_WIDGET_ENABLED: 1 + FASTID_TELEGRAM_NOTIFICATION_ENABLED: 1 + FASTID_TELEGRAM_BOT_TOKEN: 123456:BOT_SECRET + FASTID_WEBHOOK_PAGE_EXPIRES_IN_SECONDS: 0 - name: Teardown test environment run: | From d5d794a38bfa17c4c03b33187470e4157da06d10 Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Sat, 6 Jun 2026 13:24:05 +0300 Subject: [PATCH 05/12] feat: add logging configuration for postgres, redis, and mailpit services --- docker-compose.observability.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml index e32b72f..aa1f573 100644 --- a/docker-compose.observability.yml +++ b/docker-compose.observability.yml @@ -10,6 +10,12 @@ x-logging: &default-logging expression: '^(?P