Skip to content

Commit

Permalink
Outer auth meta (#184)
Browse files Browse the repository at this point in the history
## Изменения
Добавил метакласс для плагинов синхронизации пароля. Их можно применять
чтобы менять пароль внешних инфраструктурных сервисов из одного места.

## Детали реализации
Создан класс OuterAuthMeta, реализующий функции управления внешним
сервисом:
- Линковки и отлинковки внешних сервисов **администратором**
- Обработки событий по смене пароля
И предоставляющий интерфейс:
- Создания/удаления/изменеия пользователя во внешнем сервисе
  • Loading branch information
dyakovri authored Jun 19, 2024
1 parent 9eebb5e commit ee638ce
Show file tree
Hide file tree
Showing 26 changed files with 437 additions and 87 deletions.
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
SHELL := /bin/bash

run:
source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf auth_backend.routes.base:app

Expand Down
2 changes: 2 additions & 0 deletions auth_backend/auth_method/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .base import AUTH_METHODS, AuthPluginMeta
from .method_mixins import LoginableMixin, RegistrableMixin
from .oauth import OauthMeta
from .outer import OuterAuthMeta
from .session import Session
from .userdata_mixin import UserdataMixin

Expand All @@ -10,6 +11,7 @@
"AUTH_METHODS",
"AuthPluginMeta",
"OauthMeta",
"OuterAuthMeta",
"LoginableMixin",
"RegistrableMixin",
"UserdataMixin",
Expand Down
67 changes: 43 additions & 24 deletions auth_backend/auth_method/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@
from fastapi import APIRouter
from sqlalchemy.orm import Session as DbSession

from auth_backend.auth_method.session import Session
from auth_backend.models.db import User, UserSession
from auth_backend.schemas.types.scopes import Scope as TypeScope
from auth_backend.models.db import AuthMethod, User, UserSession
from auth_backend.settings import get_settings
from auth_backend.utils.user_session_control import create_session


logger = logging.getLogger(__name__)
Expand All @@ -34,30 +31,13 @@ def get_name(cls) -> str:

def __init__(self):
self.router = APIRouter()
self.router.add_api_route("/registration", self._register, methods=["POST"])
self.router.add_api_route("/login", self._login, methods=["POST"], response_model=Session)

def __init_subclass__(cls, **kwargs):
if cls.__name__.endswith('Meta') or cls.__name__.endswith('Mixin'):
return
logger.info(f'Init authmethod {cls.__name__}')
AUTH_METHODS[cls.__name__] = cls

@staticmethod
async def _create_session(
user: User, scopes_list_names: list[TypeScope] | None, session_name: str | None = None, *, db_session: DbSession
) -> Session:
"""Создает сессию пользователя"""
return await create_session(user, scopes_list_names, db_session=db_session, session_name=session_name)

@staticmethod
async def _create_user(*, db_session: DbSession) -> User:
"""Создает пользователя"""
user = User()
db_session.add(user)
db_session.flush()
return user

async def _get_user(
*,
db_session: DbSession,
Expand Down Expand Up @@ -130,10 +110,11 @@ async def user_updated(
if len(exceptions) > 0:
logger.error("Following errors occurred during on_user_update: ")
for exc in exceptions:
logger.error(exc)
if exc:
logger.error(exc)

@staticmethod
async def on_user_update(new_user: dict[str, Any], old_user: dict[str, Any] | None = None):
@classmethod
async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any] | None = None):
"""Произвести действия на обновление пользователя, в т.ч. обновление в других провайдерах
Описания входных параметров соответствует параметрам `AuthMethodMeta.user_updated`.
Expand All @@ -148,3 +129,41 @@ def active_auth_methods() -> Iterable[type['AuthPluginMeta']]:
for method in AUTH_METHODS.values():
if method.is_active():
yield method

@classmethod
def create_auth_method_param(
cls,
key: str,
value: str | int,
user_id: int,
*,
db_session: DbSession,
) -> AuthMethod:
"""Добавление пользователю новый AuthMethod"""
return AuthMethod.create(
user_id=user_id,
auth_method=cls.get_name(),
param=key,
value=str(value),
session=db_session,
)

@classmethod
def get_auth_method_params(
cls,
user_id: int,
*,
session: DbSession,
) -> dict[str, AuthMethod]:
retval: dict[str, AuthMethod] = {}
methods: list[AuthMethod] = (
AuthMethod.query(session=session)
.filter(
AuthMethod.user_id == user_id,
AuthMethod.auth_method == cls.get_name(),
)
.all()
)
for method in methods:
retval[method.param] = method
return retval
21 changes: 21 additions & 0 deletions auth_backend/auth_method/method_mixins.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
from abc import ABCMeta, abstractmethod

from sqlalchemy.orm import Session as DbSession

from auth_backend.auth_method.session import Session
from auth_backend.models.db import User
from auth_backend.schemas.types.scopes import Scope as TypeScope
from auth_backend.utils.user_session_control import create_session

from .base import AuthPluginMeta
from .session import Session

Expand All @@ -19,6 +26,13 @@ def __init__(self):
async def _register(*args, **kwargs) -> object:
raise NotImplementedError()

@staticmethod
async def _create_user(*, db_session: DbSession) -> User:
"""Создает пользователя"""
user = User.create(session=db_session)
db_session.flush()
return user


class LoginableMixin(AuthPluginMeta, metaclass=ABCMeta):
"""Сообщает что AuthMethod поддерживает вход
Expand All @@ -34,3 +48,10 @@ def __init__(self):
@abstractmethod
async def _login(*args, **kwargs) -> Session:
raise NotImplementedError()

@staticmethod
async def _create_session(
user: User, scopes_list_names: list[TypeScope] | None, session_name: str | None = None, *, db_session: DbSession
) -> Session:
"""Создает сессию пользователя"""
return await create_session(user, scopes_list_names, db_session=db_session, session_name=session_name)
11 changes: 0 additions & 11 deletions auth_backend/auth_method/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,6 @@ async def _get_user(cls, key: str, value: str | int, *, db_session: DbSession) -
if auth_method:
return auth_method.user

@classmethod
async def _register_auth_method(cls, key: str, value: str | int, user: User, *, db_session) -> AuthMethod:
"""Добавление пользователю новый AuthMethod"""
return AuthMethod.create(
user_id=user.id,
auth_method=cls.get_name(),
param=key,
value=str(value),
session=db_session,
)

@classmethod
async def _delete_auth_methods(cls, user: User, *, db_session) -> list[AuthMethod]:
"""Удаляет пользователю все AuthMethod конкретной авторизации"""
Expand Down
186 changes: 186 additions & 0 deletions auth_backend/auth_method/outer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import logging
from abc import ABCMeta, abstractmethod
from typing import Any

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 auth_backend.auth_method.base import AuthPluginMeta
from auth_backend.base import Base
from auth_backend.models.db import AuthMethod, UserSession
from auth_backend.utils.security import UnionAuth


logger = logging.getLogger(__name__)


class OuterAuthException(Exception):
"""Базовый класс для исключений внешнего сервиса"""


class UserLinkingConflict(HTTPException, OuterAuthException):
"""Пользователь уже привязан к другому аккаунту"""

def __init__(self, user_id):
super().__init__(status_code=HTTP_409_CONFLICT, detail=f"User id={user_id} already linked")


class UserNotLinked(HTTPException, OuterAuthException):
"""Пользователь не привязан к аккаунту"""

def __init__(self, user_id):
super().__init__(status_code=HTTP_404_NOT_FOUND, detail=f"User id={user_id} not linked")


class UserLinkingForbidden(HTTPException, OuterAuthException):
"""У пользователя недостаточно прав для привязки аккаунта к внешнему сервису"""

def __init__(self):
super().__init__(status_code=HTTP_403_FORBIDDEN, detail="Not authorized")


class GetOuterAccount(Base):
username: str


class LinkOuterAccount(Base):
username: str


class OuterAuthMeta(AuthPluginMeta, metaclass=ABCMeta):
"""Позволяет подключить внешний сервис для синхронизации пароля"""

__BASE_SCOPE: str

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"

@classmethod
def get_scope(cls):
"""Права, необходимые пользователю для получения данных о внешнем аккаунте"""
return cls.__BASE_SCOPE + ".read"

@classmethod
def post_scope(cls):
"""Права, необходимые пользователю для создания данных о внешнем аккаунте"""
return cls.__BASE_SCOPE + ".create"

@classmethod
def delete_scope(cls):
"""Права, необходимые пользователю для удаления данных о внешнем аккаунте"""
return cls.__BASE_SCOPE + ".delete"

@classmethod
@abstractmethod
async def _is_outer_user_exists(cls, username: str) -> bool:
"""Проверяет наличие пользователя во внешнем сервисе"""
raise NotImplementedError()

@classmethod
@abstractmethod
async def _update_outer_user_password(cls, username: str, password: str):
"""Устанавливает пользователю новый пароль в внешнем сервисе"""
raise NotImplementedError()

@classmethod
async def __get_username(cls, user_id: int) -> AuthMethod:
auth_params = cls.get_auth_method_params(user_id, session=db.session)
username = auth_params.get("username")
if not username:
logger.debug("User user_id=%d have no username in outer service %s", user_id, cls.get_name())
return
return username

@classmethod
async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any] | None = None):
"""Произвести действия на обновление пользователя, в т.ч. обновление в других провайдерах
Описания входных параметров соответствует параметрам `AuthMethodMeta.user_updated`.
"""
if not new_user or not old_user:
# Пользователь был только что создан или удален
# Тут не будет дополнительных методов
return

user_id = new_user.get("user_id")
password = new_user.get("email", {}).get("password")
if not password:
# В этом событии пароль не обновлялся, ничего не делаем
return

username = await cls.__get_username(user_id)
if not username:
# У пользователя нет имени во внешнем сервисе
return

if await cls._is_outer_user_exists(username.value):
await cls._update_outer_user_password(username.value, password)
else:
# Мы не нашли этого пользователя во внешнем сервисе
# Разорвем связку и кинем лог
username.is_deleted = True
logger.error(
"User id=%d has username %s, which can't be found in %s",
user_id,
username.value,
cls.get_name(),
)

@classmethod
async def _get_link(
cls,
user_id: int,
request_user: UserSession = Depends(UnionAuth()),
) -> GetOuterAccount:
"""Получить данные внешнего аккаунт пользователя
Получить данные может администратор или сам пользователь
"""
if cls.get_scope() not in (s.name for s in request_user.scopes) and request_user.id != user_id:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authorized")
username = await cls.__get_username(user_id)
if not username:
raise UserNotLinked(user_id)
return GetOuterAccount(username=username.value)

@classmethod
async def _link(
cls,
user_id: int,
outer: LinkOuterAccount,
request_user: UserSession = Depends(UnionAuth()),
) -> GetOuterAccount:
"""Привязать пользователю внешний аккаунт
Привязать аккаунт может только администратор
"""
if cls.post_scope() not in (s.name for s in request_user.scopes):
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authorized")
username = await cls.__get_username(user_id)
if username:
raise UserLinkingConflict(user_id)
param = cls.create_auth_method_param("username", outer.username, user_id, db_session=db.session)
return GetOuterAccount(username=param.value)

@classmethod
async def _unlink(
cls,
user_id: int,
request_user: UserSession = Depends(UnionAuth()),
):
"""Отвязать внешний аккаунт пользователю
Удалить данные может администратор
"""
if cls.delete_scope() not in (s.name for s in request_user.scopes):
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authorized")
username = await cls.__get_username(user_id)
if not username:
raise UserNotLinked(user_id)
username.is_deleted = True
Loading

0 comments on commit ee638ce

Please sign in to comment.