diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml index 6d5f6374..72557e86 100644 --- a/.github/workflows/build_and_publish.yml +++ b/.github/workflows/build_and_publish.yml @@ -114,6 +114,14 @@ jobs: --env VK_CLIENT_ID='${{ secrets.VK_CLIENT_ID }}' \ --env VK_CLIENT_ACCESS_TOKEN='${{ secrets.VK_CLIENT_ACCESS_TOKEN }}' \ --env VK_CLIENT_SECRET='${{ secrets.VK_CLIENT_SECRET }}' \ + --env AIRFLOW_AUTH_BASE_URL='${{ vars.AIRFLOW_AUTH_BASE_URL }}' \ + --env AIRFLOW_AUTH_ADMIN_USERNAME='${{ secrets.AIRFLOW_AUTH_ADMIN_USERNAME }}' \ + --env AIRFLOW_AUTH_ADMIN_PASSWORD='${{ secrets.AIRFLOW_AUTH_ADMIN_PASSWORD }}' \ + --env CODER_AUTH_BASE_URL='${{ vars.CODER_AUTH_BASE_URL }}' \ + --env CODER_AUTH_ADMIN_TOKEN='${{ secrets.CODER_AUTH_ADMIN_TOKEN }}' \ + --env MAILU_AUTH_BASE_URL='${{ vars.MAILU_AUTH_BASE_URL }}' \ + --env MAILU_AUTH_API_KEY='${{ secrets.MAILU_AUTH_API_KEY }}' \ + --env POSTGRES_AUTH_DB_DSN='${{ secrets.POSTGRES_AUTH_DB_DSN }}' \ --env ENCRYPTION_KEY='${{ secrets.ENCRYPTION_KEY }}' \ --env KAFKA_DSN='${{ secrets.KAFKA_DSN }}' \ --env KAFKA_LOGIN='${{ secrets.KAFKA_LOGIN }}' \ @@ -190,6 +198,14 @@ jobs: --env VK_CLIENT_ID='${{ secrets.VK_CLIENT_ID }}' \ --env VK_CLIENT_ACCESS_TOKEN='${{ secrets.VK_CLIENT_ACCESS_TOKEN }}' \ --env VK_CLIENT_SECRET='${{ secrets.VK_CLIENT_SECRET }}' \ + --env AIRFLOW_AUTH_BASE_URL='${{ vars.AIRFLOW_AUTH_BASE_URL }}' \ + --env AIRFLOW_AUTH_ADMIN_USERNAME='${{ secrets.AIRFLOW_AUTH_ADMIN_USERNAME }}' \ + --env AIRFLOW_AUTH_ADMIN_PASSWORD='${{ secrets.AIRFLOW_AUTH_ADMIN_PASSWORD }}' \ + --env CODER_AUTH_BASE_URL='${{ vars.CODER_AUTH_BASE_URL }}' \ + --env CODER_AUTH_ADMIN_TOKEN='${{ secrets.CODER_AUTH_ADMIN_TOKEN }}' \ + --env MAILU_AUTH_BASE_URL='${{ vars.MAILU_AUTH_BASE_URL }}' \ + --env MAILU_AUTH_API_KEY='${{ secrets.MAILU_AUTH_API_KEY }}' \ + --env POSTGRES_AUTH_DB_DSN='${{ secrets.POSTGRES_AUTH_DB_DSN }}' \ --env ENCRYPTION_KEY='${{ secrets.ENCRYPTION_KEY }}' \ --env KAFKA_DSN='${{ secrets.KAFKA_DSN }}' \ --env KAFKA_LOGIN='${{ secrets.KAFKA_LOGIN }}' \ diff --git a/Makefile b/Makefile index ae999720..a35a88a5 100644 --- a/Makefile +++ b/Makefile @@ -35,18 +35,29 @@ create-user: create-admin: source ./venv/bin/activate && python -m auth_backend user create --email test-admin@profcomff.com --password string - source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.create --comment auth.group.create --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.delete --comment auth.group.delete --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.read --comment auth.group.read --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.update --comment auth.group.update --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.create --comment auth.scope.create --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.delete --comment auth.scope.delete --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.read --comment auth.scope.read --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.update --comment auth.scope.update --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.delete --comment auth.user.delete --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.read --comment auth.user.read --creator 1 - source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.update --comment auth.user.update --creator 1 - source ./venv/bin/activate && python -m auth_backend group create --name root --scopes 1 2 3 4 5 6 7 8 9 10 11 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.create --comment auth.group.create --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.delete --comment auth.group.delete --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.read --comment auth.group.read --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.update --comment auth.group.update --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.create --comment auth.scope.create --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.delete --comment auth.scope.delete --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.read --comment auth.scope.read --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.update --comment auth.scope.update --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.delete --comment auth.user.delete --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.read --comment auth.user.read --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.update --comment auth.user.update --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.airflow_outer_auth.link.read --comment auth.airflow_outer_auth.link.read --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.airflow_outer_auth.link.create --comment auth.airflow_outer_auth.link.create --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.airflow_outer_auth.link.delete --comment auth.airflow_outer_auth.link.delete --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.coder_outer_auth.link.read --comment auth.coder_outer_auth.link.read --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.coder_outer_auth.link.create --comment auth.coder_outer_auth.link.create --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.coder_outer_auth.link.delete --comment auth.coder_outer_auth.link.delete --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.mailu_outer_auth.link.read --comment auth.mailu_outer_auth.link.read --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.mailu_outer_auth.link.create --comment auth.mailu_outer_auth.link.create --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.mailu_outer_auth.link.delete --comment auth.mailu_outer_auth.link.delete --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.postgres_outer_auth.link.read --comment auth.postgres_outer_auth.link.read --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.postgres_outer_auth.link.create --comment auth.postgres_outer_auth.link.create --creator 1 + source ./venv/bin/activate && python -m auth_backend scope create --name auth.postgres_outer_auth.link.delete --comment auth.postgres_outer_auth.link.delete --creator 1 source ./venv/bin/activate && python -m auth_backend user_group create --user_id 1 --group_id 1 login-user: diff --git a/auth_backend/auth_method/base.py b/auth_backend/auth_method/base.py index 7cfda66a..6dc58aa7 100644 --- a/auth_backend/auth_method/base.py +++ b/auth_backend/auth_method/base.py @@ -107,11 +107,11 @@ async def user_updated( *[m.on_user_update(new_user, old_user) for m in AuthPluginMeta.active_auth_methods()], return_exceptions=True, ) + exceptions = [exc for exc in exceptions if exc] if len(exceptions) > 0: logger.error("Following errors occurred during on_user_update: ") for exc in exceptions: - if exc: - logger.error(exc) + logger.error(exc, exc_info=exc) @classmethod async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any] | None = None): diff --git a/auth_backend/auth_method/outer.py b/auth_backend/auth_method/outer.py index 2611b876..23153308 100644 --- a/auth_backend/auth_method/outer.py +++ b/auth_backend/auth_method/outer.py @@ -5,7 +5,7 @@ from fastapi import Depends from fastapi.exceptions import HTTPException from fastapi_sqlalchemy import db -from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT +from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, HTTP_424_FAILED_DEPENDENCY from auth_backend.auth_method.base import AuthPluginMeta from auth_backend.base import Base @@ -34,6 +34,13 @@ def __init__(self, user_id): super().__init__(status_code=HTTP_404_NOT_FOUND, detail=f"User id={user_id} not linked") +class ConnectionIssue(HTTPException, OuterAuthException): + """Ошибка запроса к внешнему сервису""" + + def __init__(self, user_id): + super().__init__(status_code=HTTP_424_FAILED_DEPENDENCY, detail=f"User id={user_id} not linked") + + class UserLinkingForbidden(HTTPException, OuterAuthException): """У пользователя недостаточно прав для привязки аккаунта к внешнему сервису""" @@ -52,29 +59,38 @@ class LinkOuterAccount(Base): class OuterAuthMeta(AuthPluginMeta, metaclass=ABCMeta): """Позволяет подключить внешний сервис для синхронизации пароля""" - __BASE_SCOPE: str + _BASE_SCOPE: str + + def __new__(cls, *args, **kwargs): + cls._BASE_SCOPE = f"auth.{cls.get_name()}.link" + logger.info( + f"Init authmethod {cls.get_name()}, scopes: %s, %s, %s", + cls.get_scope(), + cls.post_scope(), + cls.delete_scope(), + ) + return super().__new__(cls) def __init__(self): super().__init__() self.router.add_api_route("/{user_id}/link", self._get_link, methods=["GET"]) self.router.add_api_route("/{user_id}/link", self._link, methods=["POST"]) - self.router.add_api_route("/{user_id}/unlink", self._unlink, methods=["DELETE"]) - self.__BASE_SCOPE = f"auth.{self.get_name()}.link" + self.router.add_api_route("/{user_id}/link", self._unlink, methods=["DELETE"]) @classmethod def get_scope(cls): """Права, необходимые пользователю для получения данных о внешнем аккаунте""" - return cls.__BASE_SCOPE + ".read" + return cls._BASE_SCOPE + ".read" @classmethod def post_scope(cls): """Права, необходимые пользователю для создания данных о внешнем аккаунте""" - return cls.__BASE_SCOPE + ".create" + return cls._BASE_SCOPE + ".create" @classmethod def delete_scope(cls): """Права, необходимые пользователю для удаления данных о внешнем аккаунте""" - return cls.__BASE_SCOPE + ".delete" + return cls._BASE_SCOPE + ".delete" @classmethod @abstractmethod @@ -103,27 +119,33 @@ async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any] Описания входных параметров соответствует параметрам `AuthMethodMeta.user_updated`. """ + # logger.debug("on_user_update class=%s started, new_user=%s, old_user=%s", cls.get_name(), new_user, old_user) if not new_user or not old_user: # Пользователь был только что создан или удален # Тут не будет дополнительных методов + logger.debug("%s not new_user or not old_user, closing", cls.get_name()) return user_id = new_user.get("user_id") password = new_user.get("email", {}).get("password") if not password: # В этом событии пароль не обновлялся, ничего не делаем + logger.debug("%s not password, closing", cls.get_name()) return username = await cls.__get_username(user_id) if not username: # У пользователя нет имени во внешнем сервисе + logger.debug("%s not username, closing", cls.get_name()) return if await cls._is_outer_user_exists(username.value): + logger.debug("%s user exists, changing password", cls.get_name()) await cls._update_outer_user_password(username.value, password) else: # Мы не нашли этого пользователя во внешнем сервисе # Разорвем связку и кинем лог + logger.debug("%s user not exists, unlinking", cls.get_name()) username.is_deleted = True logger.error( "User id=%d has username %s, which can't be found in %s", @@ -131,6 +153,7 @@ async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any] username.value, cls.get_name(), ) + logger.debug("on_user_update class=%s finished", cls.get_name()) @classmethod async def _get_link( @@ -184,3 +207,4 @@ async def _unlink( if not username: raise UserNotLinked(user_id) username.is_deleted = True + db.session.commit() diff --git a/auth_backend/auth_plugins/__init__.py b/auth_backend/auth_plugins/__init__.py index c45c2dc3..8dfbc700 100644 --- a/auth_backend/auth_plugins/__init__.py +++ b/auth_backend/auth_plugins/__init__.py @@ -1,12 +1,16 @@ from auth_backend.auth_method import AUTH_METHODS, AuthPluginMeta +from .airflow import AirflowOuterAuth +from .coder import CoderOuterAuth from .email import Email from .github import GithubAuth from .google import GoogleAuth from .keycloak import KeycloakAuth from .lkmsu import LkmsuAuth +from .mailu import MailuOuterAuth from .mymsu import MyMsuAuth from .physics import PhysicsAuth +from .postgres import PostgresOuterAuth from .telegram import TelegramAuth from .vk import VkAuth from .yandex import YandexAuth @@ -15,7 +19,9 @@ __all__ = [ "AUTH_METHODS", "AuthPluginMeta", + # Основной провайдер "Email", + # Oauth провайдеры "GoogleAuth", "PhysicsAuth", "LkmsuAuth", @@ -25,4 +31,9 @@ "VkAuth", "GithubAuth", "KeycloakAuth", + # Провайдеры синхронизации паролей + "PostgresOuterAuth", + "CoderOuterAuth", + "AirflowOuterAuth", + "MailuOuterAuth", ] diff --git a/auth_backend/auth_plugins/airflow.py b/auth_backend/auth_plugins/airflow.py new file mode 100644 index 00000000..759e4cc9 --- /dev/null +++ b/auth_backend/auth_plugins/airflow.py @@ -0,0 +1,55 @@ +import logging + +import aiohttp +from pydantic import AnyUrl + +from auth_backend.auth_method.outer import ConnectionIssue, OuterAuthMeta +from auth_backend.settings import Settings + + +logger = logging.getLogger(__name__) + + +class AirflowOuterAuthSettings(Settings): + AIRFLOW_AUTH_BASE_URL: AnyUrl | None = None + AIRFLOW_AUTH_ADMIN_USERNAME: str | None = None + AIRFLOW_AUTH_ADMIN_PASSWORD: str | None = None + + +class AirflowOuterAuth(OuterAuthMeta): + prefix = '/airflow' + settings = AirflowOuterAuthSettings() + + @classmethod + async def _is_outer_user_exists(cls, username: str) -> bool: + """Проверяет наличие пользователя в Airflow""" + logger.debug("_is_outer_user_exists class=%s started", cls.get_name()) + async with aiohttp.ClientSession() as session: + async with session.get( + str(cls.settings.AIRFLOW_AUTH_BASE_URL).removesuffix('/') + '/auth/fab/v1/users/' + username, + auth=aiohttp.BasicAuth( + cls.settings.AIRFLOW_AUTH_ADMIN_USERNAME, cls.settings.AIRFLOW_AUTH_ADMIN_PASSWORD + ), + ) as response: + if not response.ok: + raise ConnectionIssue(response.text) + res: dict[str] = await response.json() + return res.get('username') == username + + @classmethod + async def _update_outer_user_password(cls, username: str, password: str): + """Устанавливает пользователю новый пароль в Airflow""" + logger.debug("_update_outer_user_password class=%s started", cls.get_name()) + res = False + async with aiohttp.ClientSession() as session: + async with session.patch( + str(cls.settings.AIRFLOW_AUTH_BASE_URL).removesuffix('/') + '/auth/fab/v1/users' + username, + auth=(cls.settings.AIRFLOW_AUTH_ADMIN_USERNAME, cls.settings.AIRFLOW_AUTH_ADMIN_PASSWORD), + json={'password': password}, + ) as response: + res = response.ok + logger.debug("_update_outer_user_password class=%s response %s", cls.get_name(), str(response.status)) + if res: + logger.info("User %s updated in Airflow", username) + else: + logger.error("User %s can't be updated in Airflow. Error: %s", username, res) diff --git a/auth_backend/auth_plugins/coder.py b/auth_backend/auth_plugins/coder.py new file mode 100644 index 00000000..2c1ecef2 --- /dev/null +++ b/auth_backend/auth_plugins/coder.py @@ -0,0 +1,52 @@ +import logging + +import aiohttp +from pydantic import AnyUrl + +from auth_backend.auth_method.outer import ConnectionIssue, OuterAuthMeta +from auth_backend.settings import Settings + + +logger = logging.getLogger(__name__) + + +class CoderOuterAuthSettings(Settings): + CODER_AUTH_BASE_URL: AnyUrl | None = None + CODER_AUTH_ADMIN_TOKEN: str | None = None + + +class CoderOuterAuth(OuterAuthMeta): + prefix = '/coder' + settings = CoderOuterAuthSettings() + + @classmethod + async def _is_outer_user_exists(cls, username: str) -> bool: + """Проверяет наличие пользователя в Coder""" + logger.debug("_is_outer_user_exists class=%s started", cls.get_name()) + async with aiohttp.ClientSession() as session: + async with session.get( + str(cls.settings.CODER_AUTH_BASE_URL).removesuffix('/') + '/api/v2/users/' + username, + headers={'Coder-Session-Token': cls.settings.CODER_AUTH_ADMIN_TOKEN, 'Accept': 'application/json'}, + ) as response: + if not response.ok: + raise ConnectionIssue(response.text) + res: dict[str] = await response.json() + return res.get('username') == username + + @classmethod + async def _update_outer_user_password(cls, username: str, password: str): + """Устанавливает пользователю новый пароль в Coder""" + logger.debug("_update_outer_user_password class=%s started", cls.get_name()) + res = False + async with aiohttp.ClientSession() as session: + async with session.put( + str(cls.settings.CODER_AUTH_BASE_URL).removesuffix('/') + '/api/v2/users/' + username + '/password', + headers={'Coder-Session-Token': cls.settings.CODER_AUTH_ADMIN_TOKEN, 'Accept': 'application/json'}, + json={'password': password}, + ) as response: + res: dict[str] = response.ok + logger.debug("_update_outer_user_password class=%s response %s", cls.get_name(), str(response.status)) + if res: + logger.info("User %s updated in Coder", username) + else: + logger.error("User %s can't be updated in Coder. Error: %s", username, res) diff --git a/auth_backend/auth_plugins/email.py b/auth_backend/auth_plugins/email.py index 2964c23c..004b6e69 100644 --- a/auth_backend/auth_plugins/email.py +++ b/auth_backend/auth_plugins/email.py @@ -350,7 +350,7 @@ async def _request_reset_email( background_tasks=background_tasks, url=f"{settings.APPLICATION_HOST}/auth/reset/email?token={token}", ) - await AuthPluginMeta.user_updated(old_user, new_user) + await AuthPluginMeta.user_updated(new_user, old_user) db.session.commit() return StatusResponseModel( status="Success", message="Email confirmation link sent", ru="Ссылка отправлена на почту" @@ -399,7 +399,7 @@ async def _reset_email(token: str, background_tasks: BackgroundTasks) -> StatusR await get_kafka_producer().produce( settings.KAFKA_USER_LOGIN_TOPIC_NAME, Email.generate_kafka_key(user.id), userdata, bg_tasks=background_tasks ) - await AuthPluginMeta.user_updated(old_user, new_user) + await AuthPluginMeta.user_updated(new_user, old_user) db.session.commit() return StatusResponseModel(status="Success", message="Email successfully changed", ru="Почта изменена") @@ -433,6 +433,7 @@ async def _request_reset_password( old_user[Email.get_name()]["salt"] = auth_params["salt"].value auth_params["hashed_password"].value = Email._hash_password(schema.new_password, salt) auth_params["salt"].value = salt + new_user[Email.get_name()]["password"] = schema.new_password new_user[Email.get_name()]["hashed_password"] = auth_params["hashed_password"].value new_user[Email.get_name()]["salt"] = auth_params["salt"].value SendEmailMessage.send( @@ -443,7 +444,7 @@ async def _request_reset_password( dbsession=db.session, background_tasks=background_tasks, ) - await AuthPluginMeta.user_updated(old_user, new_user) + await AuthPluginMeta.user_updated(new_user, old_user) db.session.commit() return StatusResponseModel( status="Success", message="Password has been successfully changed", ru="Пароль изменен" @@ -509,7 +510,7 @@ async def _request_reset_forgotten_password( background_tasks=background_tasks, url=f"{settings.APPLICATION_HOST}/auth/reset/password?token={auth_params['reset_token'].value}", ) - await AuthPluginMeta.user_updated(old_user, new_user) + await AuthPluginMeta.user_updated(new_user, old_user) db.session.commit() return StatusResponseModel( status="Success", message="Reset link has been successfully mailed", ru="Ссылка отправлена на почту" @@ -545,7 +546,7 @@ async def _reset_forgotten_password( auth_params["salt"].value = salt new_user[Email.get_name()]["salt"] = auth_params["salt"].value auth_params["reset_token"].is_deleted = True - await AuthPluginMeta.user_updated(old_user, new_user) + await AuthPluginMeta.user_updated(new_user, old_user) db.session.commit() return StatusResponseModel( status="Success", message="Password has been successfully changed", ru="Пароль изменен" diff --git a/auth_backend/auth_plugins/mailu.py b/auth_backend/auth_plugins/mailu.py new file mode 100644 index 00000000..cd1eab58 --- /dev/null +++ b/auth_backend/auth_plugins/mailu.py @@ -0,0 +1,54 @@ +import logging + +import aiohttp +from pydantic import AnyUrl + +from auth_backend.auth_method.outer import ConnectionIssue, OuterAuthMeta +from auth_backend.settings import Settings + + +logger = logging.getLogger(__name__) + + +class MailuOuterAuthSettings(Settings): + MAILU_AUTH_BASE_URL: AnyUrl | None = None + MAILU_AUTH_API_KEY: str | None = None + + +class MailuOuterAuth(OuterAuthMeta): + prefix = '/mailu' + settings = MailuOuterAuthSettings() + + @classmethod + async def _is_outer_user_exists(cls, username: str) -> bool: + """Проверяет наличие пользователя на сервере Mailu""" + logger.debug("_is_outer_user_exists class=%s started", cls.get_name()) + async with aiohttp.ClientSession() as session: + async with session.get( + str(cls.settings.MAILU_AUTH_BASE_URL).removesuffix('/') + '/api/v1/user/' + username, + headers={"Authorization": cls.settings.MAILU_AUTH_API_KEY}, + ) as response: + if not response.ok: + raise ConnectionIssue(response.text) + res: dict[str] = await response.json() + return res.get('email') == username + + @classmethod + async def _update_outer_user_password(cls, username: str, password: str): + """Устанавливает пользователю новый пароль на сервере Mailu""" + logger.debug("_update_outer_user_password class=%s started", cls.get_name()) + res = False + async with aiohttp.ClientSession() as session: + async with session.patch( + str(cls.settings.MAILU_AUTH_BASE_URL).removesuffix('/') + '/api/v1/user/' + username, + headers={"Authorization": cls.settings.MAILU_AUTH_API_KEY}, + json={'raw_password': password}, + ) as response: + if not response.ok: + raise ConnectionIssue(response.text) + res: dict[str] = response.ok + logger.debug("_update_outer_user_password class=%s response %s", cls.get_name(), str(response.status)) + if res: + logger.info("User %s updated in Mailu", username) + else: + logger.error("User %s can't be updated in Mailu. Error: %s", username, res) diff --git a/auth_backend/auth_plugins/postgres.py b/auth_backend/auth_plugins/postgres.py new file mode 100644 index 00000000..94def5ba --- /dev/null +++ b/auth_backend/auth_plugins/postgres.py @@ -0,0 +1,63 @@ +import logging +import re +from contextlib import contextmanager +from typing import Generator + +from pydantic import PostgresDsn +from sqlalchemy import Result, create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +from auth_backend.auth_method import OuterAuthMeta +from auth_backend.settings import Settings + + +logger = logging.getLogger(__name__) + + +class PostgresOuterAuthSettings(Settings): + POSTGRES_AUTH_DB_DSN: PostgresDsn = 'postgresql://postgres@localhost:5432/postgres' + + +class PostgresOuterAuth(OuterAuthMeta): + prefix = '/postgres' + settings = PostgresOuterAuthSettings() + __sessionmaker: type[Session] | None = None + + @classmethod + @contextmanager + def _session(cls) -> Generator[Session, None, None]: + if not cls.__sessionmaker: + engine = create_engine(str(cls.settings.POSTGRES_AUTH_DB_DSN), pool_pre_ping=True) + cls.__sessionmaker = sessionmaker(engine) + with cls.__sessionmaker() as conn: + conn: Session + with conn.begin(): + yield conn + + @classmethod + async def _is_outer_user_exists(cls, username: str) -> bool: + """Проверяет наличие пользователя в Postgres""" + logger.debug("_is_outer_user_exists class=%s started", cls.get_name()) + with cls._session() as session: + exists = session.execute( + text("SELECT 1 FROM pg_roles WHERE rolname=:username;"), + {"username": username}, + ).scalar() # returns 1 or None + return bool(exists) + + @classmethod + async def _update_outer_user_password(cls, username: str, password: str): + """Устанавливает пользователю новый пароль в Postgres""" + logger.debug("_update_outer_user_password class=%s started", cls.get_name()) + try: + with cls._session() as session: + if len(re.findall(r"\W", username)) > 0: + raise ValueError(f"Username {username} contains invalid characters") + res: Result = session.execute( + text(f"ALTER USER {username} WITH PASSWORD :password;"), + {"password": password}, + ) + logger.debug("_update_outer_user_password class=%s response %s", cls.get_name(), str(res)) + logger.info("User %s updated in Postgres", username) + except: + logger.error("User %s can't be updated in Postgres", username) diff --git a/auth_backend/cli/scope.py b/auth_backend/cli/scope.py index 16fffff1..84490e96 100644 --- a/auth_backend/cli/scope.py +++ b/auth_backend/cli/scope.py @@ -9,7 +9,6 @@ def create_scope(name: str, creator_id: int, comment: str, session: Session) -> if Scope.query(session=session).filter(Scope.name == name).one_or_none(): print("Scope already exists") exit(errno.EIO) - scope = Scope(name=name, creator_id=creator_id, comment=comment) - session.add(scope) + scope = Scope.create(name=name, creator_id=creator_id, comment=comment, session=session) session.commit() print(f"Created scope: {scope}") diff --git a/logging_dev.conf b/logging_dev.conf index 78372724..6404b645 100644 --- a/logging_dev.conf +++ b/logging_dev.conf @@ -18,4 +18,4 @@ level=DEBUG args=(sys.stdout,) [formatter_main] -format=%(asctime)s %(levelname)-8s %(name)-15s %(message)s +format=%(asctime)s %(levelname)-8s %(name)-15s:%(lineno)d (%(funcName)s) %(message)s