diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6158067..d0a3bd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,13 +68,9 @@ jobs: run: | poetry run make testcov env: - 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_VK_OAUTH_ENABLED: 1 + FASTID_TEST_DB_URL: postgresql+asyncpg://${{ matrix.database-user }}:${{ matrix.database-password }}@${{ matrix.database-host }}:${{ matrix.database-port }}/${{ matrix.database-name }} + FASTID_TEST_REDIS_URL: redis://${{ matrix.redis-username }}:${{ matrix.redis-password }}@${{ matrix.redis-host }}:${{ matrix.redis-port }} 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 diff --git a/docs/docs/tutorial/social.md b/docs/docs/tutorial/social.md index af622c2..32257e8 100644 --- a/docs/docs/tutorial/social.md +++ b/docs/docs/tutorial/social.md @@ -3,22 +3,16 @@ Social login allows users to authenticate using their existing accounts from popular platforms like **Google**, **Yandex**, **Telegram** and others. -To enable social login, you need to -register your application with the respective social platform and obtain client credentials. Then, you can configure -FastID to use these credentials. +To enable social login, register your application with the respective social platform and obtain client credentials. +Then open **Admin → Settings → OAuth Providers**, edit the provider, enter its credentials, and enable it. All supported +providers are created in a disabled state. OAuth credentials are no longer read from environment variables. ## Google Visit [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) to obtain client credentials. -Add the following to your `.env` file: - -``` -FASTID_GOOGLE_OAUTH_ENABLED=1 -FASTID_GOOGLE_CLIENT_ID=... -FASTID_GOOGLE_CLIENT_SECRET=... -``` +In the Admin panel, enter the Google **Client ID** and **Client Secret**, then enable Google. ![google_consent.png](../img/google_consent.png) @@ -26,13 +20,7 @@ FASTID_GOOGLE_CLIENT_SECRET=... Visit [https://oauth.yandex.ru](https://oauth.yandex.ru) to obtain client credentials. -Add the following to your `.env` file: - -``` -FASTID_YANDEX_OAUTH_ENABLED=1 -FASTID_YANDEX_CLIENT_ID=... -FASTID_YANDEX_CLIENT_SECRET=... -``` +In the Admin panel, enter the Yandex **Client ID** and **Client Secret**, then enable Yandex. ![yandex_consent.png](../img/yandex_consent.png) @@ -42,11 +30,6 @@ Visit [https://t.me/BotFather](https://t.me/BotFather) to create a new bot and o bot in the BotFather settings. -Add the following to your `.env` file: - -``` -FASTID_TELEGRAM_OAUTH_ENABLED=1 -FASTID_TELEGRAM_BOT_TOKEN=... -``` +In the Admin panel, put the bot token in **Client Secret** and enable Telegram. Telegram does not use **Client ID**. ![telegram_consent.png](../img/telegram_consent.png) diff --git a/fastid/admin/factory.py b/fastid/admin/factory.py index 1c7cf6f..13064ca 100644 --- a/fastid/admin/factory.py +++ b/fastid/admin/factory.py @@ -10,6 +10,7 @@ from fastid.admin.views.settings import ( AppAdmin, EmailTemplateAdmin, + OAuthProviderAdmin, TelegramTemplateAdmin, WebhookAdmin, WebhookEventAdmin, @@ -17,6 +18,8 @@ from fastid.admin.views.versioning import ( AppVersionAdmin, EmailTemplateVersionAdmin, + OAuthAccountVersionAdmin, + OAuthProviderVersionAdmin, TelegramTemplateVersionAdmin, TransactionAdmin, UserVersionAdmin, @@ -50,6 +53,7 @@ def create(self) -> FastAPI: admin.add_view(NotificationAdmin) # Settings admin.add_view(AppAdmin) + admin.add_view(OAuthProviderAdmin) admin.add_view(WebhookAdmin) admin.add_view(WebhookEventAdmin) admin.add_view(EmailTemplateAdmin) @@ -57,7 +61,9 @@ def create(self) -> FastAPI: # Versioning admin.add_view(TransactionAdmin) admin.add_view(UserVersionAdmin) + admin.add_view(OAuthAccountVersionAdmin) admin.add_view(AppVersionAdmin) + admin.add_view(OAuthProviderVersionAdmin) admin.add_view(WebhookVersionAdmin) admin.add_view(EmailTemplateVersionAdmin) admin.add_view(TelegramTemplateVersionAdmin) diff --git a/fastid/admin/views/settings.py b/fastid/admin/views/settings.py index d2556e1..9187cff 100644 --- a/fastid/admin/views/settings.py +++ b/fastid/admin/views/settings.py @@ -2,7 +2,7 @@ from fastid.admin.views.base import BaseView from fastid.admin.views.utils import json_format -from fastid.database.models import App, EmailTemplate, TelegramTemplate, Webhook, WebhookEvent +from fastid.database.models import App, EmailTemplate, OAuthProvider, TelegramTemplate, Webhook, WebhookEvent class AppAdmin(BaseView, model=App): @@ -26,6 +26,34 @@ class AppAdmin(BaseView, model=App): ] +class OAuthProviderAdmin(BaseView, model=OAuthProvider): + can_create = False + can_delete = False + + name = "OAuth Provider" + name_plural = "OAuth Providers" + icon = "fa-solid fa-right-to-bracket" + category = "Settings" + + column_list = [ + OAuthProvider.id, + OAuthProvider.name, + OAuthProvider.enabled, + OAuthProvider.client_id, + OAuthProvider.created_at, + OAuthProvider.updated_at, + ] + column_filters = [ + BooleanFilter(OAuthProvider.enabled), + AllUniqueStringValuesFilter(OAuthProvider.name), + ] + form_columns = [ + OAuthProvider.enabled, + OAuthProvider.client_id, + OAuthProvider.client_secret, + ] + + class EmailTemplateAdmin(BaseView, model=EmailTemplate): name = "Email Template" name_plural = "Email Templates" diff --git a/fastid/admin/views/versioning.py b/fastid/admin/views/versioning.py index 1e75926..42f6e42 100644 --- a/fastid/admin/views/versioning.py +++ b/fastid/admin/views/versioning.py @@ -5,6 +5,8 @@ from fastid.database.versioning import ( AppVersion, EmailTemplateVersion, + OAuthAccountVersion, + OAuthProviderVersion, TelegramTemplateVersion, Transaction, UserVersion, @@ -66,11 +68,21 @@ class UserVersionAdmin(BaseVersionView, model=UserVersion): name_plural = "User Versions" +class OAuthAccountVersionAdmin(BaseVersionView, model=OAuthAccountVersion): + name = "OAuth Account Version" + name_plural = "OAuth Account Versions" + + class AppVersionAdmin(BaseVersionView, model=AppVersion): name = "App Version" name_plural = "App Versions" +class OAuthProviderVersionAdmin(BaseVersionView, model=OAuthProviderVersion): + name = "OAuth Provider Version" + name_plural = "OAuth Provider Versions" + + class EmailTemplateVersionAdmin(BaseVersionView, model=EmailTemplateVersion): name = "Email Template Version" name_plural = "Email Template Versions" diff --git a/fastid/cache/config.py b/fastid/cache/config.py index f9c4263..ae37d7b 100644 --- a/fastid/cache/config.py +++ b/fastid/cache/config.py @@ -1,3 +1,4 @@ +from pydantic import AliasChoices, Field from pydantic_settings import SettingsConfigDict from fastid.core.config import branding_settings @@ -5,7 +6,13 @@ class RedisSettings(BaseSettings): - url: str = "redis://default+changethis@localhost:6379/0" + url: str = Field( + default="redis://default+changethis@localhost:6379/0", + validation_alias=AliasChoices( + f"{ENV_PREFIX}redis_url", + f"{ENV_PREFIX}test_redis_url", + ), + ) major_key: str = branding_settings.service_name decode_responses: bool = True pool_size: int = 20 diff --git a/fastid/core/lifespan.py b/fastid/core/lifespan.py index 053f228..a21811d 100644 --- a/fastid/core/lifespan.py +++ b/fastid/core/lifespan.py @@ -11,6 +11,8 @@ from fastid.database.uow import SQLAlchemyUOW from fastid.notify.repositories import EmailTemplateSlugSpecification, TelegramTemplateSlugSpecification from fastid.notify.utils import collect_email_templates, collect_telegram_templates +from fastid.oauth.models import OAUTH_PROVIDER_NAMES, OAuthProvider +from fastid.oauth.repositories import OAuthProviderNameSpecification from fastid.webhooks.senders.dependencies import client as webhooks_client @@ -25,6 +27,7 @@ async def on_startup(self) -> None: async with self.cache.lock("seed", blocking=True): await self.create_admin() await self.create_templates() + await self.create_oauth_providers() except LockError: pass @@ -55,6 +58,15 @@ async def create_templates(self) -> None: except NoResultFoundError: await self.uow.telegram_templates.add(telegram_t) + async def create_oauth_providers(self) -> None: + for name in OAUTH_PROVIDER_NAMES: + try: + await self.uow.oauth_providers.find(OAuthProviderNameSpecification(name)) + except NoResultFoundError: + await self.uow.oauth_providers.add( + OAuthProvider(name=name, enabled=False, client_id="", client_secret=""), + ) + async def on_shutdown(self) -> None: await self.cache.client.aclose() await webhooks_client.aclose() diff --git a/fastid/database/config.py b/fastid/database/config.py index b800ca1..955e5a2 100644 --- a/fastid/database/config.py +++ b/fastid/database/config.py @@ -1,13 +1,18 @@ from typing import Any -from pydantic import Field, PositiveInt +from pydantic import AliasChoices, Field, PositiveInt from pydantic_settings import SettingsConfigDict from fastid.core.schemas import ENV_PREFIX, BaseSettings class DBSettings(BaseSettings): - url: str + url: str = Field( + validation_alias=AliasChoices( + f"{ENV_PREFIX}db_url", + f"{ENV_PREFIX}test_db_url", + ), + ) echo: bool = False # --- pool settings --- diff --git a/fastid/database/models.py b/fastid/database/models.py index ab7cc68..e977e0a 100644 --- a/fastid/database/models.py +++ b/fastid/database/models.py @@ -5,7 +5,7 @@ from fastid.auth.models import User from fastid.database.base import BaseOrm from fastid.notify.models import EmailTemplate, Notification, TelegramTemplate -from fastid.oauth.models import OAuthAccount +from fastid.oauth.models import OAuthAccount, OAuthProvider from fastid.webhooks.models import Webhook, WebhookEvent configure_mappers() @@ -14,6 +14,8 @@ from fastid.database.versioning import ( # noqa: E402 AppVersion, EmailTemplateVersion, + OAuthAccountVersion, + OAuthProviderVersion, TelegramTemplateVersion, Transaction, UserVersion, @@ -24,6 +26,7 @@ "App", "BaseOrm", "OAuthAccount", + "OAuthProvider", "User", "EmailTemplate", "TelegramTemplate", @@ -36,4 +39,6 @@ "WebhookVersion", "Webhook", "WebhookEvent", + "OAuthAccountVersion", + "OAuthProviderVersion", ] diff --git a/fastid/database/uow.py b/fastid/database/uow.py index 69ee8b4..3df0eb7 100644 --- a/fastid/database/uow.py +++ b/fastid/database/uow.py @@ -8,7 +8,7 @@ from fastid.apps.repositories import AppRepository from fastid.auth.repositories import UserRepository from fastid.notify.repositories import EmailTemplateRepository, NotificationRepository, TelegramTemplateRepository -from fastid.oauth.repositories import OAuthAccountRepository +from fastid.oauth.repositories import OAuthAccountRepository, OAuthProviderRepository from fastid.webhooks.repositories import WebhookEventRepository, WebhookRepository if TYPE_CHECKING: @@ -23,6 +23,7 @@ class SQLAlchemyUOW: users: UserRepository oauth_accounts: OAuthAccountRepository + oauth_providers: OAuthProviderRepository apps: AppRepository email_templates: EmailTemplateRepository telegram_templates: TelegramTemplateRepository @@ -40,6 +41,7 @@ async def begin(self) -> None: self.session = self.session_factory() self.users = UserRepository(self.session) self.oauth_accounts = OAuthAccountRepository(self.session) + self.oauth_providers = OAuthProviderRepository(self.session) self.apps = AppRepository(self.session) self.email_templates = EmailTemplateRepository(self.session) self.telegram_templates = TelegramTemplateRepository(self.session) diff --git a/fastid/database/versioning.py b/fastid/database/versioning.py index aaa5ac0..5868dcb 100644 --- a/fastid/database/versioning.py +++ b/fastid/database/versioning.py @@ -3,12 +3,15 @@ from fastid.apps.models import App from fastid.auth.models import User from fastid.notify.models import EmailTemplate, TelegramTemplate +from fastid.oauth.models import OAuthAccount, OAuthProvider from fastid.webhooks.models import Webhook Transaction = transaction_class(User) UserVersion = version_class(User) +OAuthAccountVersion = version_class(OAuthAccount) AppVersion = version_class(App) +OAuthProviderVersion = version_class(OAuthProvider) EmailTemplateVersion = version_class(EmailTemplate) TelegramTemplateVersion = version_class(TelegramTemplate) WebhookVersion = version_class(Webhook) diff --git a/fastid/frontend/factory.py b/fastid/frontend/factory.py index 621cd6f..d386e90 100644 --- a/fastid/frontend/factory.py +++ b/fastid/frontend/factory.py @@ -10,7 +10,6 @@ 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.metadata import UI_META routers = [pages_router] @@ -55,4 +54,3 @@ def _set_templates_env(self) -> None: 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"] = UI_META diff --git a/fastid/frontend/router.py b/fastid/frontend/router.py index 7411931..3fbfbfd 100644 --- a/fastid/frontend/router.py +++ b/fastid/frontend/router.py @@ -18,6 +18,7 @@ from fastid.frontend.templating import templates from fastid.notify.schemas import UserAction from fastid.oauth.dependencies import OAuthAccountsDep +from fastid.oauth.metadata import ProvidersMetaDep router = APIRouter() @@ -30,26 +31,29 @@ def index() -> Response: @router.get("/register") def register( request: Request, + providers_meta: ProvidersMetaDep, user: Annotated[User | None, Depends(get_user_or_none)], ) -> Response: if user: return RedirectResponse(url="/profile") - return templates.TemplateResponse("register.html", {"request": request}) + return templates.TemplateResponse("register.html", {"request": request, "providers_meta": providers_meta}) @router.get("/login") def login( request: Request, + providers_meta: ProvidersMetaDep, user: Annotated[User | None, Depends(get_user_or_none)], ) -> Response: if user: return RedirectResponse(url="/profile") - return templates.TemplateResponse("authorize.html", {"request": request}) + return templates.TemplateResponse("authorize.html", {"request": request, "providers_meta": providers_meta}) @router.get("/authorize") async def authorize( request: Request, + providers_meta: ProvidersMetaDep, user: Annotated[User | None, Depends(get_user_or_none)], consent: Annotated[OAuth2ConsentRequest, Depends(valid_consent)], authorization_code_grant: Annotated[AuthorizationCodeGrant, Depends()], @@ -58,7 +62,10 @@ async def authorize( assert consent.client_id is not None app = await authorization_code_grant.validate_client(consent.client_id) request.session["consent"] = consent.model_dump(mode="json") - return templates.TemplateResponse("authorize.html", {"request": request, "app": app}) + return templates.TemplateResponse( + "authorize.html", + {"request": request, "app": app, "providers_meta": providers_meta}, + ) # User is authenticated, redirect to specified redirect URI with code request.session.clear() redirect_uri = await authorization_code_grant.approve_consent(consent, user) @@ -68,6 +75,7 @@ async def authorize( @router.get("/profile") async def profile( request: Request, + providers_meta: ProvidersMetaDep, oauth_accounts: OAuthAccountsDep, user: Annotated[User, Depends(get_user)], ) -> Response: @@ -75,7 +83,12 @@ async def profile( connected = {a.provider for a in page.items} return templates.TemplateResponse( "profile.html", - {"request": request, "user": user, "connected_providers": connected}, + { + "request": request, + "user": user, + "connected_providers": connected, + "providers_meta": providers_meta, + }, ) diff --git a/fastid/integrations/config.py b/fastid/integrations/config.py index f4630e7..54ef783 100644 --- a/fastid/integrations/config.py +++ b/fastid/integrations/config.py @@ -2,19 +2,6 @@ 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 = "" - - vk_oauth_enabled: bool = False - vk_client_id: str = "" - vk_client_secret: str = "" - - telegram_widget_enabled: bool = False telegram_notification_enabled: bool = False telegram_bot_token: str = "" diff --git a/fastid/integrations/dependencies.py b/fastid/integrations/dependencies.py index ff7d448..77df622 100644 --- a/fastid/integrations/dependencies.py +++ b/fastid/integrations/dependencies.py @@ -7,6 +7,7 @@ from fastid.auth.server import ServerURLDep from fastid.core.config import core_settings from fastid.core.urls import join_url_path +from fastid.database.dependencies import UOWDep from fastid.integrations.config import integration_settings from fastid.integrations.google.oauth import GoogleSSO from fastid.integrations.registries import OAuth2Registry @@ -15,59 +16,81 @@ from fastid.integrations.vk.oauth import VKSSO from fastid.integrations.yandex.oauth import YandexSSO from fastid.oauth.exceptions import OAuthProviderDisabledError +from fastid.oauth.models import OAuthProvider +from fastid.oauth.repositories import OAuthProviderNameSpecification -def get_google_sso(redirect_uri: str) -> GoogleSSO: - if not integration_settings.google_oauth_enabled: +async def get_oauth_provider(uow: UOWDep, name: str) -> OAuthProvider: + return await uow.oauth_providers.find(OAuthProviderNameSpecification(name)) + + +async def get_telegram_provider(uow: UOWDep) -> OAuthProvider: + return await get_oauth_provider(uow, "telegram") + + +TelegramProviderDep = Annotated[OAuthProvider, Depends(get_telegram_provider)] + + +def get_google_sso(redirect_uri: str, provider: OAuthProvider) -> GoogleSSO: + if not provider.enabled: raise OAuthProviderDisabledError return GoogleSSO( - integration_settings.google_client_id, - integration_settings.google_client_secret, + provider.client_id, + provider.client_secret, redirect_uri, ) -def get_yandex_sso(redirect_uri: str) -> YandexSSO: - if not integration_settings.yandex_oauth_enabled: +def get_yandex_sso(redirect_uri: str, provider: OAuthProvider) -> YandexSSO: + if not provider.enabled: raise OAuthProviderDisabledError return YandexSSO( - integration_settings.yandex_client_id, - integration_settings.yandex_client_secret, + provider.client_id, + provider.client_secret, redirect_uri, ) -def get_vk_sso(redirect_uri: str) -> VKSSO: - if not integration_settings.vk_oauth_enabled: +def get_vk_sso(redirect_uri: str, provider: OAuthProvider) -> VKSSO: + if not provider.enabled: raise OAuthProviderDisabledError return VKSSO( - integration_settings.vk_client_id, - integration_settings.vk_client_secret, + provider.client_id, + provider.client_secret, redirect_uri, ) -def get_registry(server_url: ServerURLDep) -> OAuth2Registry: +async def get_registry(server_url: ServerURLDep, uow: UOWDep) -> OAuth2Registry: registry = OAuth2Registry() callback_url = f"{server_url}{join_url_path(core_settings.api_path, 'oauth/callback')}" - registry.provider("google")(lambda: get_google_sso(f"{callback_url}/google")) - registry.provider("yandex")(lambda: get_yandex_sso(f"{callback_url}/yandex")) - registry.provider("vk")(lambda: get_vk_sso(f"{callback_url}/vk")) + google = await get_oauth_provider(uow, "google") + yandex = await get_oauth_provider(uow, "yandex") + vk = await get_oauth_provider(uow, "vk") + registry.provider("google")(lambda: get_google_sso(f"{callback_url}/google", google)) + registry.provider("yandex")(lambda: get_yandex_sso(f"{callback_url}/yandex", yandex)) + registry.provider("vk")(lambda: get_vk_sso(f"{callback_url}/vk", vk)) return registry OAuth2RegistryDep = Annotated[OAuth2Registry, Depends(get_registry)] -def get_telegram_widget(server_url: ServerURLDep) -> TelegramLoginWidget: +def build_telegram_widget(server_url: str, provider: OAuthProvider) -> TelegramLoginWidget: + if not provider.enabled: + raise OAuthProviderDisabledError oauth_url = f"{server_url}{join_url_path(core_settings.api_path, 'oauth')}" return TelegramLoginWidget( - integration_settings.telegram_bot_token, + provider.client_secret, f"{oauth_url}/redirect/telegram", f"{oauth_url}/callback/telegram", ) +def get_telegram_widget(server_url: ServerURLDep, provider: TelegramProviderDep) -> TelegramLoginWidget: + return build_telegram_widget(server_url, provider) + + TelegramWidgetDep = Annotated[TelegramLoginWidget, Depends(get_telegram_widget)] diff --git a/fastid/oauth/metadata.py b/fastid/oauth/metadata.py index 7859d8e..ca759c6 100644 --- a/fastid/oauth/metadata.py +++ b/fastid/oauth/metadata.py @@ -1,8 +1,11 @@ +from typing import Annotated + +from fastapi import Depends + from fastid.core.config import core_settings from fastid.core.urls import join_url_path -from fastid.integrations.config import ( - integration_settings, -) +from fastid.database.dependencies import UOWDep +from fastid.integrations.dependencies import get_oauth_provider from fastid.oauth.schemas import UIProviderMeta, UIProviderMetaEntry UI_META = UIProviderMeta() @@ -18,7 +21,7 @@ color="#F44336", authorization_url=f"{BASE_AUTHORIZATION_URL}/google", revoke_url=f"{BASE_REVOKE_URL}/google", - enabled=integration_settings.google_oauth_enabled, + enabled=False, ) UI_META.providers["telegram"] = UIProviderMetaEntry( name="telegram", @@ -27,7 +30,7 @@ color="#03A9F4", authorization_url=f"{BASE_AUTHORIZATION_URL}/telegram", revoke_url=f"{BASE_REVOKE_URL}/telegram", - enabled=integration_settings.telegram_widget_enabled, + enabled=False, ) UI_META.providers["yandex"] = UIProviderMetaEntry( name="yandex", @@ -36,7 +39,7 @@ color="#EA4335", authorization_url=f"{BASE_AUTHORIZATION_URL}/yandex", revoke_url=f"{BASE_REVOKE_URL}/yandex", - enabled=integration_settings.yandex_oauth_enabled, + enabled=False, ) UI_META.providers["vk"] = UIProviderMetaEntry( name="vk", @@ -45,5 +48,15 @@ color="#0077FF", authorization_url=f"{BASE_AUTHORIZATION_URL}/vk", revoke_url=f"{BASE_REVOKE_URL}/vk", - enabled=integration_settings.vk_oauth_enabled, + enabled=False, ) + + +async def get_ui_meta(uow: UOWDep) -> UIProviderMeta: + meta = UI_META.model_copy(deep=True) + for name, entry in meta.providers.items(): + entry.enabled = (await get_oauth_provider(uow, name)).enabled + return meta + + +ProvidersMetaDep = Annotated[UIProviderMeta, Depends(get_ui_meta)] diff --git a/fastid/oauth/models.py b/fastid/oauth/models.py index 0f6dc03..f8fae0e 100644 --- a/fastid/oauth/models.py +++ b/fastid/oauth/models.py @@ -6,7 +6,9 @@ from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship -from fastid.database.base import Entity +from fastid.database.base import VersionedEntity + +OAUTH_PROVIDER_NAMES = ("google", "telegram", "yandex", "vk") if TYPE_CHECKING: from fastid.auth.models import User @@ -14,7 +16,7 @@ from fastid.oauth.schemas import OpenIDBearer -class OAuthAccount(Entity): +class OAuthAccount(VersionedEntity): __tablename__ = "oauth_accounts" user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), index=True) @@ -42,3 +44,12 @@ def from_open_id(cls, open_id: OpenIDBearer, user_id: UUIDv7) -> Self: account_id=open_id.id, user_id=user_id, ) + + +class OAuthProvider(VersionedEntity): + __tablename__ = "oauth_providers" + + name: Mapped[str] = mapped_column(unique=True) + enabled: Mapped[bool] = mapped_column(default=False) + client_id: Mapped[str] = mapped_column(default="") + client_secret: Mapped[str] = mapped_column(default="") diff --git a/fastid/oauth/repositories.py b/fastid/oauth/repositories.py index 5dae44c..a2c27b8 100644 --- a/fastid/oauth/repositories.py +++ b/fastid/oauth/repositories.py @@ -3,13 +3,25 @@ from fastid.database.repository import SQLAlchemyRepository from fastid.database.specification import Specification from fastid.database.utils import UUIDv7 -from fastid.oauth.models import OAuthAccount +from fastid.oauth.models import OAuthAccount, OAuthProvider class OAuthAccountRepository(SQLAlchemyRepository[OAuthAccount]): model_type = OAuthAccount +class OAuthProviderRepository(SQLAlchemyRepository[OAuthProvider]): + model_type = OAuthProvider + + +class OAuthProviderNameSpecification(Specification): + def __init__(self, name: str) -> None: + self.name = name + + def apply(self, stmt: Any) -> Any: + return stmt.where(OAuthProvider.name == self.name) + + class UserAccountSpecification(Specification): def __init__(self, user_id: UUIDv7, provider: str) -> None: self.user_id = user_id diff --git a/fastid/oauth/schemas.py b/fastid/oauth/schemas.py index 329d92f..d6b079f 100644 --- a/fastid/oauth/schemas.py +++ b/fastid/oauth/schemas.py @@ -55,7 +55,7 @@ class UIProviderMeta(BaseModel): @property def enabled_providers(self) -> Sequence[UIProviderMetaEntry]: - return list(self.providers.values()) + return [meta for meta in self.providers.values() if meta.enabled] @property def any_enabled(self) -> bool: diff --git a/fastid/oauth/use_cases.py b/fastid/oauth/use_cases.py index d7f108b..a3565d4 100644 --- a/fastid/oauth/use_cases.py +++ b/fastid/oauth/use_cases.py @@ -9,12 +9,10 @@ from fastid.database.dependencies import UOWDep from fastid.database.exceptions import NoResultFoundError from fastid.database.schemas import LimitOffset, Page, Sorting -from fastid.integrations.config import integration_settings -from fastid.integrations.dependencies import OAuth2RegistryDep, TelegramWidgetDep +from fastid.integrations.dependencies import OAuth2RegistryDep, TelegramProviderDep, build_telegram_widget from fastid.integrations.schemas import TelegramCallback from fastid.oauth.exceptions import ( OAuthAccountInUseError, - OAuthProviderDisabledError, ) from fastid.oauth.models import OAuthAccount from fastid.oauth.repositories import ( @@ -32,12 +30,12 @@ def __init__( self, uow: UOWDep, registry: OAuth2RegistryDep, - telegram_widget: TelegramWidgetDep, + telegram_provider: TelegramProviderDep, server_url: ServerURLDep, ) -> None: self.uow = uow self.registry = registry - self.telegram_widget = telegram_widget + self.telegram_provider = telegram_provider self.server_url = server_url async def get_login_url(self, provider: str) -> str: @@ -101,9 +99,7 @@ async def revoke(self, user: User, provider: str) -> OAuthAccount: def _get_client(self, provider: str) -> Any: if provider == "telegram": - if not integration_settings.telegram_widget_enabled: - raise OAuthProviderDisabledError - return self.telegram_widget + return build_telegram_widget(self.server_url, self.telegram_provider) return self.registry.get(provider) async def _callback(self, provider: str, callback: OAuth2Callback | TelegramCallback) -> OpenIDBearer: diff --git a/migrations/versions/2026_07_16_1933-bfa61bae871d_add_oauth_providers.py b/migrations/versions/2026_07_16_1933-bfa61bae871d_add_oauth_providers.py new file mode 100644 index 0000000..1385c6c --- /dev/null +++ b/migrations/versions/2026_07_16_1933-bfa61bae871d_add_oauth_providers.py @@ -0,0 +1,114 @@ +"""add_oauth_providers + +Revision ID: bfa61bae871d +Revises: 3dc1a72c7ad5 +Create Date: 2026-07-16 19:33:35.491894 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "bfa61bae871d" +down_revision: str | None = "3dc1a72c7ad5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "oauth_accounts_version", + sa.Column("id", sa.Uuid(), autoincrement=False, nullable=False), + sa.Column("user_id", sa.Uuid(), autoincrement=False, nullable=True), + sa.Column("provider", sa.String(), autoincrement=False, nullable=True), + sa.Column("account_id", sa.String(), autoincrement=False, nullable=True), + sa.Column("access_token", sa.String(), autoincrement=False, nullable=True), + sa.Column("expires_in", sa.BigInteger(), autoincrement=False, nullable=True), + sa.Column("scope", sa.String(), autoincrement=False, nullable=True), + sa.Column("id_token", sa.String(), autoincrement=False, nullable=True), + sa.Column("refresh_token", sa.String(), autoincrement=False, nullable=True), + sa.Column("email", sa.String(), autoincrement=False, nullable=True), + sa.Column("first_name", sa.String(), autoincrement=False, nullable=True), + sa.Column("last_name", sa.String(), autoincrement=False, nullable=True), + sa.Column("display_name", sa.String(), autoincrement=False, nullable=True), + sa.Column("picture", sa.String(), autoincrement=False, nullable=True), + sa.Column("created_at", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("updated_at", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("transaction_id", sa.BigInteger(), autoincrement=False, nullable=False), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint("id", "transaction_id", name=op.f("oauth_accounts_version_pkey")), + ) + op.create_index(op.f("oauth_accounts_version_email_idx"), "oauth_accounts_version", ["email"], unique=False) + op.create_index( + op.f("oauth_accounts_version_end_transaction_id_idx"), + "oauth_accounts_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("oauth_accounts_version_operation_type_idx"), "oauth_accounts_version", ["operation_type"], unique=False + ) + op.create_index( + op.f("oauth_accounts_version_transaction_id_idx"), "oauth_accounts_version", ["transaction_id"], unique=False + ) + op.create_index(op.f("oauth_accounts_version_user_id_idx"), "oauth_accounts_version", ["user_id"], unique=False) + op.create_table( + "oauth_providers", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("client_id", sa.String(), nullable=False), + sa.Column("client_secret", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("oauth_providers_pkey")), + sa.UniqueConstraint("name", name=op.f("oauth_providers_name_key")), + ) + op.create_table( + "oauth_providers_version", + sa.Column("id", sa.Uuid(), autoincrement=False, nullable=False), + sa.Column("name", sa.String(), autoincrement=False, nullable=True), + sa.Column("enabled", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column("client_id", sa.String(), autoincrement=False, nullable=True), + sa.Column("client_secret", sa.String(), autoincrement=False, nullable=True), + sa.Column("created_at", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("updated_at", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column("transaction_id", sa.BigInteger(), autoincrement=False, nullable=False), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint("id", "transaction_id", name=op.f("oauth_providers_version_pkey")), + ) + op.create_index( + op.f("oauth_providers_version_end_transaction_id_idx"), + "oauth_providers_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("oauth_providers_version_operation_type_idx"), "oauth_providers_version", ["operation_type"], unique=False + ) + op.create_index( + op.f("oauth_providers_version_transaction_id_idx"), "oauth_providers_version", ["transaction_id"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("oauth_providers_version_transaction_id_idx"), table_name="oauth_providers_version") + op.drop_index(op.f("oauth_providers_version_operation_type_idx"), table_name="oauth_providers_version") + op.drop_index(op.f("oauth_providers_version_end_transaction_id_idx"), table_name="oauth_providers_version") + op.drop_table("oauth_providers_version") + op.drop_table("oauth_providers") + op.drop_index(op.f("oauth_accounts_version_user_id_idx"), table_name="oauth_accounts_version") + op.drop_index(op.f("oauth_accounts_version_transaction_id_idx"), table_name="oauth_accounts_version") + op.drop_index(op.f("oauth_accounts_version_operation_type_idx"), table_name="oauth_accounts_version") + op.drop_index(op.f("oauth_accounts_version_end_transaction_id_idx"), table_name="oauth_accounts_version") + op.drop_index(op.f("oauth_accounts_version_email_idx"), table_name="oauth_accounts_version") + op.drop_table("oauth_accounts_version") + # ### end Alembic commands ### diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 75781e6..ec3d949 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -39,6 +39,7 @@ async def _start_app() -> None: tasks = LifespanTasks(uow_factory=get_test_uow, cache_factory=get_test_cache) async with tasks: await tasks.create_templates() + await tasks.create_oauth_providers() @pytest.fixture diff --git a/tests/api/oauth/conftest.py b/tests/api/oauth/conftest.py new file mode 100644 index 0000000..f1fe40c --- /dev/null +++ b/tests/api/oauth/conftest.py @@ -0,0 +1,19 @@ +import pytest + +from fastid.database.schemas import LimitOffset, Sorting +from fastid.database.uow import SQLAlchemyUOW + + +@pytest.fixture(autouse=True) +async def _enable_oauth_providers(_start_app: None, uow: SQLAlchemyUOW) -> None: + providers = await uow.oauth_providers.get_many( + pagination=LimitOffset(limit=10), + sorting=Sorting(), + ) + for provider in providers.items: + provider.enabled = True + provider.client_id = f"{provider.name}-client-id" + provider.client_secret = ( + "123456789:test-telegram-client-secret" if provider.name == "telegram" else f"{provider.name}-client-secret" + ) + await uow.commit() diff --git a/tests/api/oauth/test_oauth_login.py b/tests/api/oauth/test_oauth_login.py index 3a00315..00ce4f2 100644 --- a/tests/api/oauth/test_oauth_login.py +++ b/tests/api/oauth/test_oauth_login.py @@ -5,6 +5,9 @@ from starlette import status from fastid.auth.schemas import TokenResponse +from fastid.database.uow import SQLAlchemyUOW +from fastid.oauth.repositories import OAuthProviderNameSpecification +from tests.mocks import faker @pytest.mark.parametrize("provider", ["google", "yandex", "vk", "telegram"]) @@ -20,3 +23,41 @@ async def test_oauth_login(client: AsyncClient, user_token: TokenResponse, provi assert params["origin"] == ["http://testserver/api/v1/oauth/redirect/telegram"] else: assert params["redirect_uri"] == [f"http://testserver/api/v1/oauth/callback/{provider}"] + + +async def test_oauth_login_uses_dynamic_provider_settings( + client: AsyncClient, + uow: SQLAlchemyUOW, + user_token: TokenResponse, +) -> None: + provider = await uow.oauth_providers.find(OAuthProviderNameSpecification("google")) + provider.client_id = faker.pystr() + provider.client_secret = faker.password() + await uow.commit() + + response = await client.get( + "/oauth/login/google", + headers={"Authorization": f"Bearer {user_token.access_token}"}, + ) + + assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT + params = parse_qs(urlsplit(response.headers["location"]).query) + assert params["client_id"] == [provider.client_id] + + +async def test_oauth_login_rejects_dynamically_disabled_provider( + client: AsyncClient, + uow: SQLAlchemyUOW, + user_token: TokenResponse, +) -> None: + provider = await uow.oauth_providers.find(OAuthProviderNameSpecification("google")) + provider.enabled = False + await uow.commit() + + response = await client.get( + "/oauth/login/google", + headers={"Authorization": f"Bearer {user_token.access_token}"}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["type"] == "oauth_provider_disabled" diff --git a/tests/config.py b/tests/config.py new file mode 100644 index 0000000..7626624 --- /dev/null +++ b/tests/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import SettingsConfigDict + +from fastid.core.schemas import ENV_FILE, ENV_PREFIX, BaseSettings + + +class TestSettings(BaseSettings): + db_url: str + redis_url: str + + model_config = SettingsConfigDict( + extra="ignore", + env_file=ENV_FILE, + env_prefix=f"{ENV_PREFIX}test_", + ) + + +test_settings = TestSettings() diff --git a/tests/conftest.py b/tests/conftest.py index 4efddb6..4033d24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ from fastid.email.dependencies import get_smtp from fastid.frontend.app import frontend_app from fastid.integrations.dependencies import get_bot +from tests.config import test_settings from tests.dependencies import ( alembic_config, get_test_cache, @@ -127,7 +128,7 @@ async def uow(uow_raw: SQLAlchemyUOW, engine: AsyncEngine) -> AsyncIterator[SQLA @pytest.fixture async def redis_client() -> AsyncIterator[Redis]: - logger.info("Test redis URL: %s", redis_settings.url) + logger.info("Test redis URL: %s", test_settings.redis_url) yield test_redis await test_redis.aclose(close_connection_pool=True) diff --git a/tests/dependencies.py b/tests/dependencies.py index 6c6f859..e55b2de 100644 --- a/tests/dependencies.py +++ b/tests/dependencies.py @@ -5,18 +5,17 @@ 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 from fastid.database.uow import SQLAlchemyUOW +from tests.config import test_settings from tests.utils.alembic import alembic_config_from_url from tests.utils.db import get_test_db_url logger = log_provider.logger(__name__) -test_db_url = get_test_db_url(db_settings.url) -db_settings.url = test_db_url +test_db_url = get_test_db_url(test_settings.db_url) alembic_config = alembic_config_from_url(test_db_url) -test_redis_pool = ConnectionPool.from_url(redis_settings.url) +test_redis_pool = ConnectionPool.from_url(test_settings.redis_url) test_redis = Redis(connection_pool=test_redis_pool) test_engine = create_async_engine(test_db_url, poolclass=pool.NullPool)